--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit d0a5e95b0eff95fcfd0924509bed427b4798a649
Parents : 8c8e2ac
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T10:10:46-05:00
feat: add RNS link API, plugin permissions, and deferred network startup
Changes
77 files changed, 6742 insertions(+), 419 deletions(-)
Diff
diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
index 30dac551..c5c14b37 100644
--- a/.github/workflows/bench.yml
+++ b/.github/workflows/bench.yml
@@ -1,11 +1,11 @@
# Benchmarks and integrity checks (push to main branches and workflow_dispatch).
-# Results are stored in a runner cache and compared on every run; the job fails
-# when any metric regresses beyond 150% of the stored baseline, and a commit
-# comment is posted with the offending numbers.
+# The suite runs 3 times; reported values are median-of-medians. A smart gate
+# (noise floor + absolute delta + adaptive ratio) decides regressions — flat
+# ratio alerts on sub-ms SQLite ops are useless on shared runners.
#
# Pinned first-party actions (bump tag and SHA together when upgrading):
# actions/checkout@v6.0.1 8e8c483db84b4bee98b60c0593521ed34d9990e8
-# actions/cache@v4.2.0 1bd1e32a3bdc45362d1e726936510720a7c30a57
+# actions/cache@v5.0.4 668228422ae6a00e4ad889ee87cd7109ec5666a7
# benchmark-action/github-action-benchmark@v1.22.0
# a60cea5bc7b49e15c1f58f411161f99e0df48372
@@ -50,17 +50,20 @@ jobs:
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Restore benchmark baseline cache
- uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7
with:
path: ./cache
- key: ${{ runner.os }}-bench-baseline-${{ github.ref_name }}
+ key: ${{ runner.os }}-bench-baseline-v2-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
+ ${{ runner.os }}-bench-baseline-v2-${{ github.ref_name }}-
+ ${{ runner.os }}-bench-baseline-v2-
${{ runner.os }}-bench-baseline-
- - name: Run benchmarks
+ - name: Run benchmarks (3 suite runs, median of medians)
run: |
set -euo pipefail
uv run python tests/backend/run_comprehensive_benchmarks.py \
+ --runs 3 \
--json-output bench_results.json 2>&1 | tee bench_results.txt
- name: Run integrity tests
@@ -68,7 +71,39 @@ jobs:
set -euo pipefail
task test:integrity 2>&1 | tee -a bench_results.txt
- - name: Store and compare benchmark results
+ - name: Smart regression gate
+ id: gate
+ run: |
+ set -euo pipefail
+ mkdir -p cache
+ set +e
+ uv run python tests/backend/compare_benchmarks.py \
+ --current bench_results.json \
+ --previous ./cache/benchmark-data.json \
+ --summary bench_gate_summary.txt \
+ --noise-floor-ms 0.5 \
+ --min-abs-delta-ms 1.5
+ code=$?
+ set -e
+ echo "exit_code=${code}" >> "$GITHUB_OUTPUT"
+ if [ "${code}" != "0" ]; then
+ echo "::error::Benchmark gate reported actionable regressions (see summary)"
+ fi
+ exit "${code}"
+
+ - name: Update benchmark baseline cache
+ if: success()
+ run: |
+ set -euo pipefail
+ uv run python tests/backend/compare_benchmarks.py \
+ --current bench_results.json \
+ --previous ./cache/benchmark-data.json \
+ --baseline-out ./cache/benchmark-data.json \
+ --update-baseline
+
+ - name: Publish benchmark chart (informational)
+ if: success()
+ continue-on-error: true
uses: benchmark-action/github-action-benchmark@a60cea5bc7b49e15c1f58f411161f99e0df48372
with:
name: MeshChatX Backend Benchmarks
@@ -76,8 +111,8 @@ jobs:
output-file-path: bench_results.json
external-data-json-path: ./cache/benchmark-data.json
github-token: ${{ secrets.GITHUB_TOKEN }}
- alert-threshold: "200%"
- fail-threshold: "300%"
- fail-on-alert: true
- comment-on-alert: true
+ # Chart/comment only — smart gate is the fail path.
+ alert-threshold: "1000%"
+ fail-on-alert: false
+ comment-on-alert: false
summary-always: true
diff --git a/Taskfile.yml b/Taskfile.yml
index 8fdd2a68..39b1bcd3 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -201,7 +201,7 @@ tasks:
fi
test:quick:be:
- desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads)
+ desc: Backend regression subset (interface-stats, CSRF, LXMF send, nomad downloads, RNS link)
cmds:
- >-
uv run pytest
@@ -209,6 +209,9 @@ tasks:
tests/backend/test_http_auth_security.py
tests/backend/test_meshchat_coverage.py
tests/backend/test_nomadnet_downloader.py
+ tests/backend/test_rns_link_manager.py
+ tests/backend/test_rns_link_fuzzing.py
+ tests/backend/test_rns_link_plugin.py
-q
test:quick:fe:
@@ -324,6 +327,19 @@ tasks:
cmds:
- uv run python tests/backend/run_comprehensive_benchmarks.py
+ bench:be:ci:
+ desc: Run backend benchmarks the way CI does (3 suite runs + smart gate)
+ cmds:
+ - >
+ uv run python tests/backend/run_comprehensive_benchmarks.py
+ --runs 3 --json-output bench_results.json
+ - >
+ uv run python tests/backend/compare_benchmarks.py
+ --current bench_results.json
+ --previous ./cache/benchmark-data.json
+ --baseline-out ./cache/benchmark-data.json
+ --summary bench_gate_summary.txt
+
bench:be:extreme:
desc: Run extreme stress benchmarks
cmds:
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 4ea3ac45..ddc4bdb6 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -103,11 +103,11 @@ public class MainActivity extends AppCompatActivity {
@Nullable
WavPcmAttachmentRecorder attachmentPcmRecorder;
private static final String[] STARTUP_PHASES = new String[] {
- "Starting MeshChatX...",
- "Initializing Reticulum network stack...",
- "Loading MeshChatX frontend...",
- "Establishing secure local connection...",
- "Finalizing startup..."
+ "Starting MeshChatX…",
+ "Getting the network ready…",
+ "Almost there…",
+ "Opening MeshChatX…",
+ "Finishing up…"
};
private static boolean isAllowedWebViewNavigationUri(Uri uri) {
if (uri == null) {
@@ -184,7 +184,7 @@ public class MainActivity extends AppCompatActivity {
loadingText = findViewById(R.id.loadingText);
errorText = findViewById(R.id.errorText);
webView.setVisibility(android.view.View.INVISIBLE);
- showLoading("Starting MeshChatX backend...");
+ showLoading("Starting MeshChatX…");
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
@@ -657,13 +657,7 @@ public class MainActivity extends AppCompatActivity {
meshchatServerStartAttempts += 1;
if (meshchatServerStartAttempts < MESHCHAT_SERVER_START_MAX_ATTEMPTS) {
backendFailed = false;
- showLoading(
- "MeshChatX backend error, retrying ("
- + meshchatServerStartAttempts
- + "/"
- + MESHCHAT_SERVER_START_MAX_ATTEMPTS
- + ")..."
- );
+ showLoading("Having trouble starting. Trying again…");
mainHandler.postDelayed(() -> startMeshChatServer(), MESHCHAT_SERVER_RETRY_DELAY_MS);
} else {
backendFailed = true;
@@ -762,7 +756,7 @@ public class MainActivity extends AppCompatActivity {
if (startupPageLoaded || backendFailed) {
return;
}
- showLoading(message + " (" + (connectionAttempts + 1) + "/" + MAX_CONNECTION_ATTEMPTS + ")");
+ showLoading(message);
long retryDelayMs = Math.min(
CONNECTION_RETRY_MAX_DELAY_MS,
CONNECTION_RETRY_INITIAL_DELAY_MS + (connectionAttempts * 250L)
@@ -777,7 +771,7 @@ public class MainActivity extends AppCompatActivity {
return;
}
webView.loadUrl(SERVER_URL);
- scheduleConnectionRetry("Retrying connection...");
+ scheduleConnectionRetry("Still starting…");
}, retryDelayMs);
}
@@ -844,11 +838,18 @@ public class MainActivity extends AppCompatActivity {
STARTUP_PHASES.length - 1,
(connectionAttempts * STARTUP_PHASES.length) / Math.max(1, MAX_CONNECTION_ATTEMPTS)
);
- String phase = STARTUP_PHASES[phaseIndex];
- if (connectionAttempts == 0) {
- return phase;
- }
- return phase + " (" + connectionAttempts + "/" + MAX_CONNECTION_ATTEMPTS + ")";
+ // Prefer friendly phase copy over technical/counter messages.
+ if (fallbackMessage == null || fallbackMessage.isEmpty()) {
+ return STARTUP_PHASES[phaseIndex];
+ }
+ String trimmed = fallbackMessage.trim();
+ if (trimmed.startsWith("Starting MeshChatX")
+ || trimmed.startsWith("Still starting")
+ || trimmed.startsWith("Retrying")
+ || trimmed.startsWith("Having trouble")) {
+ return STARTUP_PHASES[phaseIndex];
+ }
+ return trimmed;
}
@Override
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground_image.png b/android/app/src/main/res/drawable/ic_launcher_foreground_image.png
index 5e4fad87..d23da2a5 100644
Binary files a/android/app/src/main/res/drawable/ic_launcher_foreground_image.png and b/android/app/src/main/res/drawable/ic_launcher_foreground_image.png differ
diff --git a/android/app/src/main/res/drawable/splash_logo.png b/android/app/src/main/res/drawable/splash_logo.png
new file mode 100644
index 00000000..3d17ee3c
Binary files /dev/null and b/android/app/src/main/res/drawable/splash_logo.png differ
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 8b1e01da..16edc24a 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -27,11 +27,14 @@
<ImageView
android:id="@+id/loadingLogo"
- android:layout_width="80dp"
- android:layout_height="80dp"
+ android:layout_width="96dp"
+ android:layout_height="96dp"
android:layout_marginBottom="20dp"
+ android:adjustViewBounds="true"
android:contentDescription="@string/app_name"
- android:src="@mipmap/ic_launcher"
+ android:padding="8dp"
+ android:scaleType="fitCenter"
+ android:src="@drawable/splash_logo"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/progressBar"
app:layout_constraintEnd_toEndOf="parent"
@@ -41,12 +44,13 @@
android:id="@+id/loadingText"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginStart="16dp"
- android:layout_marginEnd="16dp"
+ android:layout_marginStart="24dp"
+ android:layout_marginEnd="24dp"
android:layout_marginTop="12dp"
android:gravity="center"
+ android:lineSpacingExtra="2dp"
android:textColor="#FFFFFFFF"
- android:textSize="14sp"
+ android:textSize="15sp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index a5e5afa1..f8fbeff3 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index a5e5afa1..f8fbeff3 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 82ca1613..4b68a6d7 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 82ca1613..4b68a6d7 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index af92a459..f062abd9 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
index af92a459..f062abd9 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index e9c5aaf7..5ec57fa2 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
index e9c5aaf7..5ec57fa2 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index c7c02d2a..1a341fa2 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
index c7c02d2a..1a341fa2 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/docs/en/architecture.md b/docs/en/architecture.md
index 6267173d..33e6e2b9 100644
--- a/docs/en/architecture.md
+++ b/docs/en/architecture.md
@@ -132,6 +132,9 @@ Practical extension paths today:
- Frontend pages wired through registries
- New settings via `ConfigManager` and CLI or environment variables
- Database schema changes through migrations
+- Generic RNS Link transport over WebSocket (`rns.link.*`) for external consoles and plugins (see **RNS Link API**)
+
+Granted plugin manager capabilities include `destinationPath.read` and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
diff --git a/docs/en/rns-link-api.md b/docs/en/rns-link-api.md
new file mode 100644
index 00000000..f9e6e3ee
--- /dev/null
+++ b/docs/en/rns-link-api.md
@@ -0,0 +1,70 @@
+# Generic RNS Link API
+
+MeshChatX exposes a generic Reticulum Link transport over the main WebSocket (`/ws`) so external apps and plugins can open links, run request/response exchanges, send packets, and tear links down without going through NomadNet-specific helpers.
+
+This is the surface used by microReticulum management consoles that treat MeshChatX as an RNS transport.
+
+## Auth
+
+When password auth is enabled, all `rns.link.*` client messages require an authenticated session (same rule as other WebSocket mutators).
+
+## Client → server
+
+| `type` | Fields | Behavior |
+| ------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `rns.link.open` | `destination_hash` (hex), `aspect` (dot-separated), `request_id`, `auto_identify?` | Open or reuse a cached link to `(aspect, destination_hash)`. Streams `phase` then `success` / `failure`. |
+| `rns.link.identify` | `destination_hash`, `aspect`, `request_id` | Call `link.identify(local_identity)` on the cached link. |
+| `rns.link.request` | `destination_hash`, `aspect`, `path`, `request_id`, `data_b64?`, `timeout?` | Ensure the link is open, then `link.request(path, data=…)`. `data_b64` / reply `body_b64` are msgpack payloads, base64-encoded. |
+| `rns.link.send` | `destination_hash`, `aspect`, `payload_b64`, `request_id` | Send a raw packet on the cached link. |
+| `rns.link.close` | `destination_hash`, `aspect`, `request_id` | Teardown and uncache the link. |
+
+`aspect` is split on `.` into RNS app name + sub-aspects (for example `microrn.mgmt`).
+
+Long-running `open` / `request` work is tracked per WebSocket client and cancelled when that client disconnects.
+
+## Server → client
+
+Per-`request_id` replies reuse the same `type` with `status` of `phase`, `progress`, `success`, or `failure`.
+
+Broadcast events:
+
+| `type` | `event` | Notes |
+| ---------------- | ----------------- | ---------------------- |
+| `rns.link.event` | `packet_received` | Includes `payload_b64` |
+| `rns.link.event` | `link_closed` | Cached link removed |
+
+## Plugin capabilities
+
+Plugins that declare the matching `permissions.managers` entries can call the same transport through `POST /api/v1/plugins/{id}/invoke` with `method: "callManager"`:
+
+- `rnsLink.open`
+- `rnsLink.identify`
+- `rnsLink.request`
+- `rnsLink.send`
+- `rnsLink.close`
+
+Subscribe to async link traffic with `permissions.hooks: ["rns.link.event"]`. Events arrive as `plugin.event` WebSocket frames with `event: "rns.link.event"`.
+
+Example manifest fragment:
+
+```json
+{
+ "permissions": {
+ "hooks": ["rns.link.event"],
+ "managers": ["rnsLink.open", "rnsLink.identify", "rnsLink.request", "rnsLink.send", "rnsLink.close"],
+ "storage": "isolated",
+ "network": "none"
+ }
+}
+```
+
+## Implementation
+
+- `meshchatx/src/backend/rns_link_manager.py` — link cache, open/identify/request/send/close
+- `meshchatx/meshchat.py` — WebSocket dispatch and per-client task tracking
+- `meshchatx/src/backend/plugin_manager.py` — capability wrappers and hook fan-out
+
+## Related
+
+- **Plugins** in Tools docs for install/enable flow
+- **Architecture** for the plugin runtime overview
diff --git a/docs/en/tools.md b/docs/en/tools.md
index bc98c672..f87c6d99 100644
--- a/docs/en/tools.md
+++ b/docs/en/tools.md
@@ -79,6 +79,12 @@ When `rrc_enabled` is on, you can run a local RRC hub from relay chat server set
Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+Plugins are capability-gated, not fully open-ended: they cannot rewrite core MeshChatX. Supported runtimes are **frontend JS** (Worker) and optional **backend WASM**. Python plugins are not supported.
+
+ZIP install shows a confirmation dialog that lists requested permissions (hooks, managers, storage, `network:fetch`) and any scanned/declared external HTTP URLs. You can deny individual grants before install; denied capabilities stay blocked at runtime. Misbehaving plugins auto-disable after an error budget.
+
+Plugins that need a generic Reticulum Link transport (for example a microReticulum node management UI) can request `rnsLink.*` manager capabilities and the `rns.link.event` hook. External web apps can use the same transport over `/ws` without installing a plugin. See **RNS Link API**.
+
Disable plugins at startup with `--disable-plugins` if you need a minimal surface.
## Command palette
diff --git a/electron/loading.html b/electron/loading.html
index 3fe5a81b..3aae7aa8 100644
--- a/electron/loading.html
+++ b/electron/loading.html
@@ -34,9 +34,9 @@
>
<div class="px-6 pt-8 pb-2 text-center">
<div
- class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white shadow-inner ring-1 ring-slate-200/80 dark:bg-zinc-950 dark:ring-zinc-700"
+ class="mx-auto mb-4 flex h-20 w-20 items-center justify-center overflow-visible rounded-2xl bg-white p-2 shadow-inner ring-1 ring-slate-200/80 dark:bg-zinc-950 dark:ring-zinc-700"
>
- <img class="h-10 w-10 object-contain" src="./assets/images/logo.png" alt="" />
+ <img class="h-14 w-14 object-contain" src="./assets/images/logo.png" alt="" />
</div>
<h1 class="text-xl font-semibold tracking-tight text-slate-900 dark:text-white">MeshChatX</h1>
<p id="status-line" class="mt-2 text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
@@ -115,6 +115,7 @@
</main>
<script src="./loadingStatusNotice.js"></script>
+ <script src="./loadingStatusProbe.js"></script>
<script>
const statusLine = document.getElementById("status-line");
const attemptHint = document.getElementById("attempt-hint");
@@ -324,6 +325,10 @@
const recentFailures = [];
function parseStatusJson(text) {
+ const helper = window.MeshchatLoadingStatusProbe;
+ if (helper && typeof helper.parseStatusJson === "function") {
+ return helper.parseStatusJson(text);
+ }
try {
return JSON.parse(text);
} catch (e) {
@@ -331,6 +336,28 @@
}
}
+ function evaluateStatusResponse(httpStatus, bodyText) {
+ const helper = window.MeshchatLoadingStatusProbe;
+ if (helper && typeof helper.evaluateStatusResponse === "function") {
+ return helper.evaluateStatusResponse(httpStatus, bodyText);
+ }
+ if (Number(httpStatus) !== 200) {
+ return {
+ ok: false,
+ failure: { kind: "http-error", status: Number(httpStatus) || 0 },
+ };
+ }
+ const data = parseStatusJson(bodyText);
+ if (data && (data.status === "ok" || data.status === "starting")) {
+ return {
+ ok: true,
+ stage: data.stage || data.status,
+ networkReady: !!data.network_ready,
+ };
+ }
+ return { ok: false, failure: { kind: "invalid-payload" } };
+ }
+
function rememberFailure(failure) {
if (!failure || typeof failure !== "object") {
return;
@@ -367,19 +394,19 @@
try {
const result = await fetch(url, { cache: "no-store" });
const text = await result.text();
- if (result.status !== 200) {
+ const evaluated = evaluateStatusResponse(result.status, text);
+ if (evaluated.ok) {
return {
- ok: false,
- failure: { kind: "http-error", status: result.status, protocol: protocol },
+ ok: true,
+ protocol: protocol,
+ stage: evaluated.stage,
+ networkReady: evaluated.networkReady,
};
}
- const data = parseStatusJson(text);
- if (data && data.status === "ok") {
- return { ok: true, protocol: protocol };
- }
+ const failure = evaluated.failure || { kind: "invalid-payload" };
return {
ok: false,
- failure: { kind: "invalid-payload", protocol: protocol },
+ failure: { ...failure, protocol: protocol },
};
} catch (error) {
return {
@@ -458,9 +485,14 @@
} catch (e) {}
}
detectedProtocol = result.protocol;
- statusLine.textContent = "Opening the app…";
+ if (result.networkReady) {
+ statusLine.textContent = "Opening the app…";
+ } else {
+ statusLine.textContent = "Opening the app (network still starting)…";
+ }
attemptHint.textContent = "";
connectionNotice.textContent = "";
+ // Theme/config may 503 until identity is ready; ignore failures.
syncThemeFromConfig();
setTimeout(onReady, 200);
return;
diff --git a/electron/loadingStatusProbe.js b/electron/loadingStatusProbe.js
new file mode 100644
index 00000000..2a0085c4
--- /dev/null
+++ b/electron/loadingStatusProbe.js
@@ -0,0 +1,60 @@
+(function (root, factory) {
+ const exported = factory();
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = exported;
+ }
+ root.MeshchatLoadingStatusProbe = exported;
+})(typeof globalThis !== "undefined" ? globalThis : window, function () {
+ function parseStatusJson(text) {
+ if (text == null) {
+ return null;
+ }
+ try {
+ return JSON.parse(String(text));
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Decide whether an HTTP status probe response means the Electron loading
+ * page can navigate into the app shell.
+ *
+ * HTTP may be up while RNS is still starting (`status: "starting"`).
+ */
+ function evaluateStatusResponse(httpStatus, bodyText) {
+ if (Number(httpStatus) !== 200) {
+ return {
+ ok: false,
+ failure: { kind: "http-error", status: Number(httpStatus) || 0 },
+ };
+ }
+ const data = parseStatusJson(bodyText);
+ if (!data || typeof data !== "object") {
+ return { ok: false, failure: { kind: "invalid-payload" } };
+ }
+ if (data.status === "failed") {
+ return {
+ ok: false,
+ failure: {
+ kind: "startup-failed",
+ error: typeof data.error === "string" ? data.error : "",
+ stage: data.stage || "failed",
+ },
+ };
+ }
+ if (data.status === "ok" || data.status === "starting") {
+ return {
+ ok: true,
+ stage: data.stage || data.status,
+ networkReady: !!data.network_ready,
+ };
+ }
+ return { ok: false, failure: { kind: "invalid-payload" } };
+ }
+
+ return {
+ parseStatusJson,
+ evaluateStatusResponse,
+ };
+});
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index f8f1a523..1961c90c 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -23,6 +23,7 @@ import re
import runpy
import secrets
import shutil
+import signal
import socket
import ssl
import sys
@@ -190,6 +191,7 @@ from meshchatx.src.backend.recovery import (
format_memory_log_line,
)
from meshchatx.src.backend import reticulum_pathfinding
+from meshchatx.src.backend.rns_link_manager import RnsLinkManager
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.sticker_utils import (
@@ -297,6 +299,47 @@ def _restore_rns_console_logging_after_reticulum_init(app) -> None:
RNS.loglevel = RNS.LOG_WARNING
+def _create_reticulum_instance(config_dir: str, loglevel: int | None = None):
+ """Construct ``RNS.Reticulum`` even when called off the main thread.
+
+ Reticulum registers SIGINT/SIGTERM handlers in ``__init__``. Python only allows
+ ``signal.signal`` on the main thread, so deferred network setup must skip that
+ registration when running in a background worker and install handlers later.
+ """
+ kwargs = {}
+ if loglevel is not None:
+ kwargs["loglevel"] = loglevel
+
+ if threading.current_thread() is threading.main_thread():
+ return RNS.Reticulum(config_dir, **kwargs)
+
+ real_signal = signal.signal
+
+ def _signal_allow_non_main(signum, handler):
+ try:
+ return real_signal(signum, handler)
+ except ValueError:
+ return signal.getsignal(signum)
+
+ signal.signal = _signal_allow_non_main
+ try:
+ return RNS.Reticulum(config_dir, **kwargs)
+ finally:
+ signal.signal = real_signal
+
+
+def _install_reticulum_signal_handlers() -> bool:
+ """Install RNS SIGINT/SIGTERM handlers. Must run on the main thread."""
+ try:
+ signal.signal(signal.SIGINT, RNS.Reticulum.sigint_handler)
+ signal.signal(signal.SIGTERM, RNS.Reticulum.sigterm_handler)
+ return True
+ except ValueError:
+ return False
+ except Exception:
+ return False
+
+
def _python_jit_status_line() -> str:
jit_runtime = getattr(sys, "_jit", None)
if jit_runtime is None:
@@ -462,6 +505,17 @@ class ReticulumMeshChat:
AsyncUtils.ensure_background_loop()
self.web_audio_bridge = WebAudioBridge(None, None)
+ self.rns_link_manager = RnsLinkManager(
+ self_identity_getter=lambda: self.identity,
+ reticulum_getter=lambda: getattr(self, "reticulum", None),
+ broadcast_event=self._on_rns_link_broadcast,
+ )
+ # Track long-running rns.link.* handler tasks per WS client so they can
+ # be cancelled when the client disconnects.
+ self._rns_link_tasks: dict[web.WebSocketResponse, set[asyncio.Task]] = {}
+ # Anchor RequestReceipts returned by link.request() for the lifetime of
+ # the request. Keyed by (client, request_id).
+ self._rns_request_receipts: dict = {}
if defer_network_setup:
self._set_startup_stage("http")
else:
@@ -1043,6 +1097,10 @@ class ReticulumMeshChat:
"http_interfaces_good": web_results.get(
"http_interfaces_good", {"status": "failed", "reason": "missing"}
),
+ "http_reticulum_instance_good": web_results.get(
+ "http_reticulum_instance_good",
+ {"status": "failed", "reason": "missing"},
+ ),
"http_identities_good": web_results.get(
"http_identities_good", {"status": "failed", "reason": "missing"}
),
@@ -1055,6 +1113,10 @@ class ReticulumMeshChat:
"websocket_good": web_results.get(
"websocket_good", {"status": "failed", "reason": "missing"}
),
+ "websocket_rns_link_good": web_results.get(
+ "websocket_rns_link_good",
+ {"status": "failed", "reason": "missing"},
+ ),
"bots_lifecycle": {
"status": "ok" if bots_ok else "failed",
"reason": bots_reason,
@@ -1254,13 +1316,42 @@ class ReticulumMeshChat:
self._startup_stage = "ready"
self._startup_error = None
self._network_ready_event.set()
+ self._schedule_reticulum_signal_handlers()
+
+ def _schedule_reticulum_signal_handlers(self) -> None:
+ """Install RNS signal handlers on the main asyncio loop when possible."""
+ if threading.current_thread() is threading.main_thread():
+ _install_reticulum_signal_handlers()
+ return
+ loop = AsyncUtils.main_loop
+ if loop is None or not loop.is_running():
+ return
+ try:
+ loop.call_soon_threadsafe(_install_reticulum_signal_handlers)
+ except Exception:
+ pass
def _startup_status_payload(self) -> dict:
+ if self._startup_stage == "failed" or self._startup_error:
+ payload = {
+ "status": "failed",
+ "stage": "failed",
+ "network_ready": False,
+ "listen_host": self.listen_host,
+ "listen_port": self.listen_port,
+ "https_enabled": self.use_https,
+ "is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ "plugins_enabled": self.plugins_enabled,
+ **self._landlock_status_dict(),
+ }
+ if self._startup_error:
+ payload["error"] = self._startup_error
+ return payload
ready = bool(self._network_ready) and bool(
self.current_context and self.current_context.running,
)
stage = "ready" if ready else (self._startup_stage or "starting")
- payload = {
+ return {
"status": "ok" if ready else "starting",
"stage": stage,
"network_ready": ready,
@@ -1271,21 +1362,28 @@ class ReticulumMeshChat:
"plugins_enabled": self.plugins_enabled,
**self._landlock_status_dict(),
}
- if self._startup_error:
- payload["error"] = self._startup_error
- return payload
def wait_until_network_ready(self, timeout: float | None = None) -> bool:
- if self._network_ready and self.current_context and self.current_context.running:
+ if (
+ self._network_ready
+ and self.current_context
+ and self.current_context.running
+ ):
return True
return self._network_ready_event.wait(timeout)
- def start_network_setup_in_background(self, identity: RNS.Identity | None = None) -> None:
+ def start_network_setup_in_background(
+ self, identity: RNS.Identity | None = None
+ ) -> None:
pending = identity if identity is not None else self._pending_identity
if pending is None:
raise RuntimeError("No identity available for network setup")
self._pending_identity = pending
- if self._network_ready and self.current_context and self.current_context.running:
+ if (
+ self._network_ready
+ and self.current_context
+ and self.current_context.running
+ ):
return
with self._network_setup_lock:
if self._network_setup_thread and self._network_setup_thread.is_alive():
@@ -1314,36 +1412,38 @@ class ReticulumMeshChat:
print(f"Failed to persist session secret into config: {exc}")
self._mark_network_ready()
print("Network stack ready", flush=True)
- try:
- AsyncUtils.run_async(
- self.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "ok",
- "stage": "ready",
- "network_ready": True,
- },
- ),
- )
- except Exception:
- pass
+ if self.websocket_clients:
+ try:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ {
+ "type": "startup_status",
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ },
+ ),
+ )
+ except Exception:
+ pass
except Exception as exc:
traceback.print_exc()
self._set_startup_stage("failed", str(exc))
- try:
- AsyncUtils.run_async(
- self.websocket_broadcast(
- {
- "type": "startup_status",
- "status": "failed",
- "stage": "failed",
- "network_ready": False,
- "error": str(exc),
- },
- ),
- )
- except Exception:
- pass
+ if self.websocket_clients:
+ try:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ {
+ "type": "startup_status",
+ "status": "failed",
+ "stage": "failed",
+ "network_ready": False,
+ "error": str(exc),
+ },
+ ),
+ )
+ except Exception:
+ pass
def setup_identity(self, identity: RNS.Identity):
identity_hash = identity.hash.hex()
@@ -1366,13 +1466,10 @@ class ReticulumMeshChat:
self._set_startup_stage("rns")
self._ensure_reticulum_config()
rns_loglevel = _resolve_rns_loglevel(self._rns_loglevel_cli)
- if rns_loglevel is not None:
- self.reticulum = RNS.Reticulum(
- self.reticulum_config_dir,
- loglevel=rns_loglevel,
- )
- else:
- self.reticulum = RNS.Reticulum(self.reticulum_config_dir)
+ self.reticulum = _create_reticulum_instance(
+ self.reticulum_config_dir,
+ loglevel=rns_loglevel,
+ )
_restore_rns_console_logging_after_reticulum_init(self)
self._set_startup_stage("identity")
self.page_node_manager.load_nodes()
@@ -6785,6 +6882,7 @@ class ReticulumMeshChat:
self.websocket_clients.remove(websocket_response)
except ValueError:
pass
+ self._cancel_rns_link_tasks_for_client(websocket_response)
return websocket_response
@@ -11919,8 +12017,8 @@ class ReticulumMeshChat:
}
)
- @routes.post("/api/v1/plugins/install")
- async def plugins_install(request):
+ @routes.post("/api/v1/plugins/preview")
+ async def plugins_preview(request):
if not self.plugins_enabled:
return web.json_response(
{"message": "Plugins are disabled"}, status=403
@@ -11934,17 +12032,74 @@ class ReticulumMeshChat:
{"message": "No plugin archive provided"}, status=400
)
payload = await field.read()
- plugin = await asyncio.to_thread(
- self.plugin_manager.install_from_zip_bytes, payload
+ else:
+ payload = await request.read()
+ if not payload:
+ return web.json_response(
+ {"message": "No plugin archive provided"}, status=400
)
- return web.json_response(plugin)
- data = await request.read()
- if not data:
+ preview = await asyncio.to_thread(
+ self.plugin_manager.preview_from_zip_bytes, payload
+ )
+ return web.json_response(preview)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
+ @routes.post("/api/v1/plugins/install")
+ async def plugins_install(request):
+ if not self.plugins_enabled:
+ return web.json_response(
+ {"message": "Plugins are disabled"}, status=403
+ )
+ try:
+ granted_permissions = None
+ payload = b""
+ if request.content_type and "multipart" in request.content_type:
+ reader = await request.multipart()
+ while True:
+ field = await reader.next()
+ if field is None:
+ break
+ name = field.name or ""
+ if name in ("archive", "file", "plugin"):
+ payload = await field.read()
+ elif name == "granted_permissions":
+ raw = await field.text()
+ try:
+ parsed = json.loads(raw)
+ except Exception:
+ parsed = None
+ if isinstance(parsed, list):
+ granted_permissions = [
+ item for item in parsed if isinstance(item, str)
+ ]
+ else:
+ content_type = request.content_type or ""
+ if "application/json" in content_type:
+ body = await request.json()
+ archive_b64 = body.get("archive_b64") or body.get("zip_b64")
+ if not archive_b64:
+ return web.json_response(
+ {"message": "No plugin archive provided"}, status=400
+ )
+ import base64
+
+ payload = base64.b64decode(archive_b64, validate=True)
+ granted = body.get("granted_permissions")
+ if isinstance(granted, list):
+ granted_permissions = [
+ item for item in granted if isinstance(item, str)
+ ]
+ else:
+ payload = await request.read()
+ if not payload:
return web.json_response(
{"message": "No plugin archive provided"}, status=400
)
plugin = await asyncio.to_thread(
- self.plugin_manager.install_from_zip_bytes, data
+ self.plugin_manager.install_from_zip_bytes,
+ payload,
+ granted_permissions,
)
return web.json_response(plugin)
except Exception as e:
@@ -17632,10 +17787,435 @@ class ReticulumMeshChat:
),
)
+ elif _type == "rns.link.open":
+ self._track_rns_link_task(
+ client,
+ asyncio.create_task(self._handle_rns_link_open(client, data)),
+ )
+
+ elif _type == "rns.link.identify":
+ await self._handle_rns_link_identify(client, data)
+
+ elif _type == "rns.link.request":
+ self._track_rns_link_task(
+ client,
+ asyncio.create_task(self._handle_rns_link_request(client, data)),
+ )
+
+ elif _type == "rns.link.send":
+ await self._handle_rns_link_send(client, data)
+
+ elif _type == "rns.link.close":
+ await self._handle_rns_link_close(client, data)
+
# unhandled type
else:
print("unhandled client message type: " + _type)
+ def _track_rns_link_task(self, client, task: asyncio.Task) -> None:
+ bucket = self._rns_link_tasks.get(client)
+ if bucket is None:
+ bucket = set()
+ self._rns_link_tasks[client] = bucket
+ bucket.add(task)
+ task.add_done_callback(lambda t, c=client: self._untrack_rns_link_task(c, t))
+
+ def _untrack_rns_link_task(self, client, task: asyncio.Task) -> None:
+ bucket = self._rns_link_tasks.get(client)
+ if not bucket:
+ return
+ bucket.discard(task)
+ if not bucket:
+ self._rns_link_tasks.pop(client, None)
+
+ def _cancel_rns_link_tasks_for_client(self, client) -> None:
+ bucket = self._rns_link_tasks.pop(client, None)
+ if bucket:
+ for task in bucket:
+ if not task.done():
+ task.cancel()
+ stale_keys = [k for k in self._rns_request_receipts if k[0] is client]
+ for key in stale_keys:
+ self._rns_request_receipts.pop(key, None)
+
+ @staticmethod
+ def _rns_link_parse_dest_aspect(data):
+ dest_hex = data.get("destination_hash")
+ aspect = data.get("aspect")
+ if not dest_hex or not aspect:
+ return None, None, "missing_destination_or_aspect"
+ try:
+ return bytes.fromhex(dest_hex), aspect, None
+ except ValueError:
+ return None, None, "invalid_destination_hash"
+
+ @staticmethod
+ async def _rns_link_send(client, payload):
+ try:
+ await client.send_str(json.dumps(payload))
+ except Exception as e:
+ print(f"rns.link reply failed: {e}")
+
+ async def _handle_rns_link_open(self, client, data):
+ request_id = data.get("request_id")
+ dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
+ if err:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.open",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": err,
+ },
+ )
+ return
+ auto_identify = bool(data.get("auto_identify", False))
+
+ def on_phase(phase):
+ AsyncUtils.run_async(
+ self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.open",
+ "request_id": request_id,
+ "status": "phase",
+ "phase": phase,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ )
+
+ link, identified, failure_reason = await self.rns_link_manager.open_link(
+ dest_hash,
+ aspect,
+ auto_identify=auto_identify,
+ on_phase=on_phase,
+ )
+ if link is None:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.open",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": failure_reason or "unknown",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ return
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.open",
+ "request_id": request_id,
+ "status": "success",
+ "identified": identified,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+
+ async def _handle_rns_link_identify(self, client, data):
+ request_id = data.get("request_id")
+ dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
+ if err:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.identify",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": err,
+ },
+ )
+ return
+ ok, failure_reason = self.rns_link_manager.identify(dest_hash, aspect)
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.identify",
+ "request_id": request_id,
+ "status": "success" if ok else "failure",
+ "failure_reason": failure_reason,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+
+ async def _handle_rns_link_request(self, client, data):
+ import base64
+
+ request_id = data.get("request_id")
+ dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
+ if err:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": err,
+ },
+ )
+ return
+ path = data.get("path")
+ if not path:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": "missing_path",
+ },
+ )
+ return
+ # data_b64 is msgpack-encoded request payload; decode to a native value
+ # so RNS.Link.request embeds it in the wire envelope correctly.
+ data_b64 = data.get("data_b64")
+ try:
+ body_bytes = base64.b64decode(data_b64, validate=True) if data_b64 else None
+ except Exception:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": "invalid_data_b64",
+ },
+ )
+ return
+ if body_bytes is None or len(body_bytes) == 0:
+ link_request_data = None
+ else:
+ try:
+ from RNS.vendor import umsgpack
+
+ link_request_data = umsgpack.unpackb(body_bytes)
+ except Exception as e:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": f"data_msgpack_decode_failed: {e}",
+ },
+ )
+ return
+ timeout = data.get("timeout")
+
+ def on_phase(phase):
+ AsyncUtils.run_async(
+ self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "phase",
+ "phase": phase,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ )
+
+ link, _identified, failure_reason = await self.rns_link_manager.open_link(
+ dest_hash,
+ aspect,
+ auto_identify=False,
+ on_phase=on_phase,
+ )
+ if link is None:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": failure_reason or "unknown",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ return
+
+ def on_response(request_receipt):
+ self._rns_request_receipts.pop((client, request_id), None)
+ raw = request_receipt.response
+ from RNS.vendor import umsgpack
+
+ try:
+ if hasattr(raw, "read") and not isinstance(raw, (bytes, bytearray)):
+ raw_to_pack = raw.read()
+ else:
+ raw_to_pack = raw
+ body_b64 = base64.b64encode(umsgpack.packb(raw_to_pack)).decode("ascii")
+ except Exception as e:
+ print(f"[rns.link.request] msgpack encode failed: {e}")
+ body_b64 = ""
+ AsyncUtils.run_async(
+ self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "success",
+ "body_b64": body_b64,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ )
+
+ def on_failed(_receipt=None):
+ self._rns_request_receipts.pop((client, request_id), None)
+ AsyncUtils.run_async(
+ self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": "request_failed",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ )
+
+ def on_progress(receipt):
+ AsyncUtils.run_async(
+ self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "progress",
+ "progress": receipt.progress,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+ )
+
+ try:
+ receipt = self.rns_link_manager.request(
+ dest_hash,
+ aspect,
+ path,
+ link_request_data,
+ response_callback=on_response,
+ failed_callback=on_failed,
+ progress_callback=on_progress,
+ timeout=timeout,
+ )
+ self._rns_request_receipts[(client, request_id)] = receipt
+ except Exception as e:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.request",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": f"request_dispatch_failed: {e}",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+
+ async def _handle_rns_link_send(self, client, data):
+ import base64
+
+ request_id = data.get("request_id")
+ dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
+ if err:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.send",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": err,
+ },
+ )
+ return
+ payload_b64 = data.get("payload_b64", "")
+ try:
+ payload = (
+ base64.b64decode(payload_b64, validate=True) if payload_b64 else b""
+ )
+ except Exception:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.send",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": "invalid_payload_b64",
+ },
+ )
+ return
+ ok, failure_reason = self.rns_link_manager.send_packet(
+ dest_hash, aspect, payload
+ )
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.send",
+ "request_id": request_id,
+ "status": "success" if ok else "failure",
+ "failure_reason": failure_reason,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+
+ async def _handle_rns_link_close(self, client, data):
+ request_id = data.get("request_id")
+ dest_hash, aspect, err = self._rns_link_parse_dest_aspect(data)
+ if err:
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.close",
+ "request_id": request_id,
+ "status": "failure",
+ "failure_reason": err,
+ },
+ )
+ return
+ ok = self.rns_link_manager.close(dest_hash, aspect)
+ await self._rns_link_send(
+ client,
+ {
+ "type": "rns.link.close",
+ "request_id": request_id,
+ "status": "success" if ok else "failure",
+ "failure_reason": None if ok else "no_active_link",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ },
+ )
+
+ def _broadcast_to_websocket_clients(self, payload: dict) -> None:
+ """Thread-safe fire-and-forget broadcast from RNS callback threads."""
+ try:
+ AsyncUtils.run_async(self.websocket_broadcast(json.dumps(payload)))
+ except Exception as e:
+ print(f"websocket broadcast failed: {e}")
+
+ def _on_rns_link_broadcast(self, payload: dict) -> None:
+ self._broadcast_to_websocket_clients(payload)
+ if payload.get("type") == "rns.link.event":
+ plugin_manager = getattr(self, "plugin_manager", None)
+ if plugin_manager is not None:
+ plugin_manager.on_rns_link_event(payload)
+
async def websocket_broadcast(self, data):
# Serialize: concurrent callers must not interleave; the second snapshot must run
# only after the first broadcast has finished mutating the live client list.
diff --git a/meshchatx/src/backend/community_interfaces_directory.py b/meshchatx/src/backend/community_interfaces_directory.py
index d2d947be..5d2cf831 100644
--- a/meshchatx/src/backend/community_interfaces_directory.py
+++ b/meshchatx/src/backend/community_interfaces_directory.py
@@ -12,10 +12,14 @@ from typing import Any
from urllib.parse import urlparse
DEFAULT_SUBMITTED_URL = (
- "https://directory.rns.recipes/api/directory/submitted?search=&type=&status=online"
+ "https://directory.rns.recipes/api/directory/submitted?status=online"
)
+DEFAULT_DISCOVERED_URL = (
+ "https://directory.rns.recipes/api/directory/discovered?status=online"
+)
+DEFAULT_DIRECTORY_URLS = (DEFAULT_SUBMITTED_URL, DEFAULT_DISCOVERED_URL)
-DESCRIPTION = "directory.rns.recipes (user-submitted, online)"
+DESCRIPTION = "directory.rns.recipes (online submitted + discovered)"
_ALLOWED_DIRECTORY_HOSTS = frozenset({"directory.rns.recipes"})
@@ -71,6 +75,26 @@ def fetch_directory_payload(url: str, *, timeout: float = 60.0) -> object:
return json.loads(resp.read().decode("utf-8"))
+def _merge_directory_rows(row_lists: list[list[Any]]) -> list[Any]:
+ merged: list[Any] = []
+ seen: set[tuple[Any, ...]] = set()
+ for rows in row_lists:
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ key = (
+ str(row.get("name") or "").strip().lower(),
+ str(row.get("type") or "").strip().lower(),
+ str(row.get("host") or row.get("address") or "").strip().lower(),
+ str(row.get("port") or "").strip(),
+ )
+ if key in seen:
+ continue
+ seen.add(key)
+ merged.append(row)
+ return merged
+
+
def build_interfaces_from_directory_url(
url: str | None = None,
*,
@@ -78,11 +102,25 @@ def build_interfaces_from_directory_url(
) -> tuple[list[dict[str, Any]], str]:
if url is not None and str(url).strip():
resolved = validate_directory_fetch_url(url)
- else:
- resolved = DEFAULT_SUBMITTED_URL
- payload = fetch_directory_payload(resolved, timeout=timeout)
- rows = rows_from_payload(payload)
- return transform_directory_rows(rows), resolved
+ payload = fetch_directory_payload(resolved, timeout=timeout)
+ rows = rows_from_payload(payload)
+ return transform_directory_rows(rows), resolved
+
+ row_lists: list[list[Any]] = []
+ used: list[str] = []
+ errors: list[str] = []
+ for candidate in DEFAULT_DIRECTORY_URLS:
+ try:
+ payload = fetch_directory_payload(candidate, timeout=timeout)
+ row_lists.append(rows_from_payload(payload))
+ used.append(candidate)
+ except Exception as exc:
+ errors.append(f"{candidate}: {exc}")
+ if not used:
+ msg = "; ".join(errors) if errors else "No directory URLs configured"
+ raise ValueError(msg)
+ rows = _merge_directory_rows(row_lists)
+ return transform_directory_rows(rows), " + ".join(used)
_RE_REMOTE = re.compile(r"^\s*remote\s*=\s*(\S+)", re.MULTILINE | re.IGNORECASE)
diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index f03ad576..88a69e96 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -22,6 +22,20 @@ from meshchatx.src.backend.plugin_guard import (
validate_wasm_file,
validate_zip_bytes,
)
+from meshchatx.src.backend.plugin_permissions import (
+ collect_network_endpoints,
+ declared_permission_ids,
+ deserialize_granted,
+ granted_allows_hook,
+ granted_allows_manager,
+ granted_allows_network_fetch,
+ granted_allows_storage,
+ normalize_granted_permissions,
+ normalize_network_mode,
+ requires_network_fetch,
+ serialize_granted,
+ validate_declared_permissions,
+)
SUPPORTED_API_VERSION = 1
PLUGIN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
@@ -48,6 +62,7 @@ class PluginRecord:
enabled: bool
install_path: str
auto_disabled_reason: str | None = None
+ granted_permissions: list[str] | None = None
announce_handlers: list[Any] = field(default_factory=list)
error_count: int = 0
last_error_at: float = 0.0
@@ -85,10 +100,19 @@ class PluginManager:
CREATE TABLE IF NOT EXISTS plugin_state (
plugin_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
- auto_disabled_reason TEXT
+ auto_disabled_reason TEXT,
+ granted_permissions TEXT
)
"""
)
+ columns = {
+ row[1]
+ for row in conn.execute("PRAGMA table_info(plugin_state)").fetchall()
+ }
+ if "granted_permissions" not in columns:
+ conn.execute(
+ "ALTER TABLE plugin_state ADD COLUMN granted_permissions TEXT"
+ )
conn.commit()
def set_app(self, app: Any) -> None:
@@ -125,7 +149,14 @@ class PluginManager:
with open(manifest_path, encoding="utf-8") as handle:
manifest = json.load(handle)
manifest = self._validate_manifest(manifest)
- enabled, auto_disabled_reason = self._read_plugin_state(manifest["id"])
+ enabled, auto_disabled_reason, granted = self._read_plugin_state(
+ manifest["id"]
+ )
+ declared = declared_permission_ids(manifest)
+ if granted is None:
+ granted = list(declared)
+ else:
+ granted = normalize_granted_permissions(declared, granted)
self._plugins[manifest["id"]] = PluginRecord(
id=manifest["id"],
version=manifest["version"],
@@ -133,33 +164,56 @@ class PluginManager:
enabled=enabled,
install_path=plugin_dir,
auto_disabled_reason=auto_disabled_reason,
+ granted_permissions=granted,
)
except Exception as exc:
print(f"Failed to load plugin from {plugin_dir}: {exc}")
- def _read_plugin_state(self, plugin_id: str) -> tuple[bool, str | None]:
+ def _read_plugin_state(
+ self, plugin_id: str
+ ) -> tuple[bool, str | None, list[str] | None]:
with sqlite3.connect(self.state_db_path) as conn:
row = conn.execute(
- "SELECT enabled, auto_disabled_reason FROM plugin_state WHERE plugin_id = ?",
+ """
+ SELECT enabled, auto_disabled_reason, granted_permissions
+ FROM plugin_state WHERE plugin_id = ?
+ """,
(plugin_id,),
).fetchone()
if not row:
- return False, None
- return bool(row[0]), row[1]
+ return False, None, None
+ return bool(row[0]), row[1], deserialize_granted(row[2])
def _write_plugin_state(
- self, plugin_id: str, enabled: bool, auto_disabled_reason: str | None = None
+ self,
+ plugin_id: str,
+ enabled: bool,
+ auto_disabled_reason: str | None = None,
+ granted_permissions: list[str] | None = None,
) -> None:
with sqlite3.connect(self.state_db_path) as conn:
conn.execute(
"""
- INSERT INTO plugin_state (plugin_id, enabled, auto_disabled_reason)
- VALUES (?, ?, ?)
+ INSERT INTO plugin_state (
+ plugin_id, enabled, auto_disabled_reason, granted_permissions
+ )
+ VALUES (?, ?, ?, ?)
ON CONFLICT(plugin_id) DO UPDATE SET
enabled = excluded.enabled,
- auto_disabled_reason = excluded.auto_disabled_reason
+ auto_disabled_reason = excluded.auto_disabled_reason,
+ granted_permissions = COALESCE(
+ excluded.granted_permissions,
+ plugin_state.granted_permissions
+ )
""",
- (plugin_id, 1 if enabled else 0, auto_disabled_reason),
+ (
+ plugin_id,
+ 1 if enabled else 0,
+ auto_disabled_reason,
+ None
+ if granted_permissions is None
+ else serialize_granted(granted_permissions),
+ ),
)
conn.commit()
@@ -183,9 +237,7 @@ class PluginManager:
raise ValueError(
f"unsupported apiVersion (expected {SUPPORTED_API_VERSION})"
)
- permissions = manifest.get("permissions") or {}
- if permissions and not isinstance(permissions, dict):
- raise ValueError("permissions must be an object")
+ validate_declared_permissions(manifest)
return manifest
def list_plugins(self) -> list[dict[str, Any]]:
@@ -208,6 +260,11 @@ class PluginManager:
def _public_plugin_view(self, record: PluginRecord) -> dict[str, Any]:
manifest = record.manifest
permissions = manifest.get("permissions") or {}
+ declared = declared_permission_ids(manifest)
+ granted = record.granted_permissions
+ if granted is None:
+ granted = list(declared)
+ endpoints = collect_network_endpoints(manifest, record.install_path)
return {
"id": record.id,
"version": record.version,
@@ -217,12 +274,59 @@ class PluginManager:
"auto_disabled_reason": record.auto_disabled_reason,
"manifest": manifest,
"permissions": permissions,
+ "declared_permissions": declared,
+ "granted_permissions": granted,
+ "network_endpoints": endpoints,
+ "requires_network_fetch": requires_network_fetch(manifest, endpoints),
"contributes": manifest.get("contributes") or {},
"has_frontend": bool(manifest.get("frontend")),
"has_backend": bool(manifest.get("backend")),
}
- def install_from_directory(self, source_dir: str) -> dict[str, Any]:
+ def _build_preview_from_directory(self, source_dir: str) -> dict[str, Any]:
+ manifest_path = os.path.join(source_dir, "plugin.json")
+ if not os.path.isfile(manifest_path):
+ raise ValueError("plugin.json not found")
+ with open(manifest_path, encoding="utf-8") as handle:
+ manifest = self._validate_manifest(json.load(handle))
+ declared = declared_permission_ids(manifest)
+ endpoints = collect_network_endpoints(manifest, source_dir)
+ network_mode = normalize_network_mode(
+ (manifest.get("permissions") or {}).get("network")
+ )
+ return {
+ "id": manifest["id"],
+ "name": manifest.get("name") or manifest["id"],
+ "version": manifest["version"],
+ "description": manifest.get("description") or "",
+ "permissions": declared,
+ "network_endpoints": endpoints,
+ "requires_network_fetch": requires_network_fetch(manifest, endpoints),
+ "network_mode": network_mode,
+ "has_frontend": bool(manifest.get("frontend")),
+ "has_backend": bool(manifest.get("backend")),
+ "manifest": manifest,
+ }
+
+ def preview_from_zip_bytes(self, payload: bytes) -> dict[str, Any]:
+ import tempfile
+
+ self._require_runtime_enabled()
+ validate_zip_bytes(payload)
+ with tempfile.TemporaryDirectory() as tmp:
+ zip_path = os.path.join(tmp, "plugin.zip")
+ with open(zip_path, "wb") as handle:
+ handle.write(payload)
+ extract_dir = os.path.join(tmp, "extract")
+ os.makedirs(extract_dir, exist_ok=True)
+ plugin_root = safe_extract_zip(zip_path, extract_dir)
+ return self._build_preview_from_directory(plugin_root)
+
+ def install_from_directory(
+ self,
+ source_dir: str,
+ granted_permissions: list[str] | None = None,
+ ) -> dict[str, Any]:
self._require_runtime_enabled()
manifest_path = os.path.join(source_dir, "plugin.json")
if not os.path.isfile(manifest_path):
@@ -230,12 +334,22 @@ class PluginManager:
with open(manifest_path, encoding="utf-8") as handle:
manifest = self._validate_manifest(json.load(handle))
plugin_id = manifest["id"]
+ declared = declared_permission_ids(manifest)
+ granted = normalize_granted_permissions(declared, granted_permissions)
target_dir = os.path.join(self.installed_dir, plugin_id)
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
with self._lock:
- enabled, auto_disabled_reason = self._read_plugin_state(plugin_id)
+ enabled, auto_disabled_reason, _existing_granted = self._read_plugin_state(
+ plugin_id
+ )
+ self._write_plugin_state(
+ plugin_id,
+ enabled,
+ auto_disabled_reason,
+ granted_permissions=granted,
+ )
self._plugins[plugin_id] = PluginRecord(
id=plugin_id,
version=manifest["version"],
@@ -243,10 +357,15 @@ class PluginManager:
enabled=enabled,
install_path=target_dir,
auto_disabled_reason=auto_disabled_reason,
+ granted_permissions=granted,
)
return self._public_plugin_view(self._plugins[plugin_id])
- def install_from_zip_bytes(self, payload: bytes) -> dict[str, Any]:
+ def install_from_zip_bytes(
+ self,
+ payload: bytes,
+ granted_permissions: list[str] | None = None,
+ ) -> dict[str, Any]:
import tempfile
validate_zip_bytes(payload)
@@ -257,7 +376,9 @@ class PluginManager:
extract_dir = os.path.join(tmp, "extract")
os.makedirs(extract_dir, exist_ok=True)
plugin_root = safe_extract_zip(zip_path, extract_dir)
- return self.install_from_directory(plugin_root)
+ return self.install_from_directory(
+ plugin_root, granted_permissions=granted_permissions
+ )
def enable(self, plugin_id: str) -> dict[str, Any]:
self._require_runtime_enabled()
@@ -358,14 +479,35 @@ class PluginManager:
def _permission_allowed(self, record: PluginRecord, capability: str) -> bool:
permissions = record.manifest.get("permissions") or {}
managers = permissions.get("managers") or []
- return capability in managers
+ if capability not in managers:
+ return False
+ return granted_allows_manager(record.granted_permissions, capability)
def _hook_allowed(self, record: PluginRecord, hook: str) -> bool:
permissions = record.manifest.get("permissions") or {}
hooks = permissions.get("hooks") or []
- return hook in hooks
+ if hook not in hooks:
+ return False
+ return granted_allows_hook(record.granted_permissions, hook)
+
+ def _network_fetch_allowed(self, record: PluginRecord) -> bool:
+ permissions = record.manifest.get("permissions") or {}
+ network = normalize_network_mode(permissions.get("network"))
+ if network != "fetch":
+ return False
+ return granted_allows_network_fetch(record.granted_permissions)
+
+ def _storage_allowed(self, record: PluginRecord) -> bool:
+ permissions = record.manifest.get("permissions") or {}
+ storage = permissions.get("storage") or "none"
+ if storage != "isolated":
+ return False
+ return granted_allows_storage(record.granted_permissions)
def storage_get(self, plugin_id: str, key: str) -> str | None:
+ record = self._require_plugin(plugin_id)
+ if not self._storage_allowed(record):
+ raise PermissionError("storage permission not granted")
with sqlite3.connect(self.state_db_path) as conn:
row = conn.execute(
"SELECT storage_value FROM plugin_storage WHERE plugin_id = ? AND storage_key = ?",
@@ -374,6 +516,9 @@ class PluginManager:
return row[0] if row else None
def storage_set(self, plugin_id: str, key: str, value: str) -> None:
+ record = self._require_plugin(plugin_id)
+ if not self._storage_allowed(record):
+ raise PermissionError("storage permission not granted")
with sqlite3.connect(self.state_db_path) as conn:
conn.execute(
"""
@@ -385,6 +530,12 @@ class PluginManager:
)
conn.commit()
+ def network_fetch_allowed(self, plugin_id: str) -> bool:
+ record = self._require_plugin(plugin_id)
+ if not record.enabled:
+ return False
+ return self._network_fetch_allowed(record)
+
def call_manager(
self, plugin_id: str, capability: str, args: dict[str, Any]
) -> Any:
@@ -395,8 +546,223 @@ class PluginManager:
raise PermissionError(f"capability not granted: {capability}")
if capability == "destinationPath.read":
return self._destination_path_read(args)
+ if capability == "rnsLink.open":
+ return self._rns_link_open(args)
+ if capability == "rnsLink.identify":
+ return self._rns_link_identify(args)
+ if capability == "rnsLink.request":
+ return self._rns_link_request(args)
+ if capability == "rnsLink.send":
+ return self._rns_link_send(args)
+ if capability == "rnsLink.close":
+ return self._rns_link_close(args)
raise ValueError(f"unknown capability: {capability}")
+ def _require_rns_link_manager(self):
+ if not self.app:
+ raise RuntimeError("app is not available")
+ manager = getattr(self.app, "rns_link_manager", None)
+ if manager is None:
+ raise RuntimeError("rns_link_manager is not available")
+ return manager
+
+ @staticmethod
+ def _parse_rns_link_args(args: dict[str, Any]) -> tuple[bytes, str]:
+ dest_hex = args.get("destination_hash")
+ aspect = args.get("aspect")
+ if not isinstance(dest_hex, str) or not dest_hex:
+ raise ValueError("destination_hash is required")
+ if not isinstance(aspect, str) or not aspect:
+ raise ValueError("aspect is required")
+ try:
+ return bytes.fromhex(dest_hex), aspect
+ except ValueError as exc:
+ raise ValueError("invalid destination_hash") from exc
+
+ def _await_rns_link_coro(self, coro, *, timeout: float = 45.0):
+ import asyncio
+
+ from meshchatx.src.backend.async_utils import AsyncUtils
+
+ loop = AsyncUtils.main_loop
+ if loop is not None and loop.is_running():
+ return asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=timeout)
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(coro)
+ raise RuntimeError("event loop is not available")
+
+ def _rns_link_open(self, args: dict[str, Any]) -> dict[str, Any]:
+ dest_hash, aspect = self._parse_rns_link_args(args)
+ manager = self._require_rns_link_manager()
+ auto_identify = bool(args.get("auto_identify", False))
+ link, identified, failure_reason = self._await_rns_link_coro(
+ manager.open_link(
+ dest_hash,
+ aspect,
+ auto_identify=auto_identify,
+ )
+ )
+ if link is None:
+ return {
+ "ok": False,
+ "failure_reason": failure_reason or "unknown",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+ return {
+ "ok": True,
+ "identified": identified,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ def _rns_link_identify(self, args: dict[str, Any]) -> dict[str, Any]:
+ dest_hash, aspect = self._parse_rns_link_args(args)
+ manager = self._require_rns_link_manager()
+ ok, failure_reason = manager.identify(dest_hash, aspect)
+ return {
+ "ok": ok,
+ "failure_reason": failure_reason,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ def _rns_link_request(self, args: dict[str, Any]) -> dict[str, Any]:
+ import base64
+
+ dest_hash, aspect = self._parse_rns_link_args(args)
+ path = args.get("path")
+ if not isinstance(path, str) or not path:
+ raise ValueError("path is required")
+ manager = self._require_rns_link_manager()
+ link, _identified, failure_reason = self._await_rns_link_coro(
+ manager.open_link(dest_hash, aspect, auto_identify=False)
+ )
+ if link is None:
+ return {
+ "ok": False,
+ "failure_reason": failure_reason or "unknown",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ data_b64 = args.get("data_b64")
+ try:
+ body_bytes = base64.b64decode(data_b64, validate=True) if data_b64 else None
+ except Exception as exc:
+ raise ValueError("invalid data_b64") from exc
+ if body_bytes is None or len(body_bytes) == 0:
+ link_request_data = None
+ else:
+ from RNS.vendor import umsgpack
+
+ try:
+ link_request_data = umsgpack.unpackb(body_bytes)
+ except Exception as exc:
+ raise ValueError(f"data_msgpack_decode_failed: {exc}") from exc
+
+ timeout = args.get("timeout")
+ done = threading.Event()
+ result: dict[str, Any] = {
+ "ok": False,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ def on_response(request_receipt):
+ raw = request_receipt.response
+ from RNS.vendor import umsgpack
+
+ try:
+ if hasattr(raw, "read") and not isinstance(raw, (bytes, bytearray)):
+ raw_to_pack = raw.read()
+ else:
+ raw_to_pack = raw
+ result["body_b64"] = base64.b64encode(
+ umsgpack.packb(raw_to_pack)
+ ).decode("ascii")
+ result["ok"] = True
+ except Exception as exc:
+ result["failure_reason"] = f"response_encode_failed: {exc}"
+ done.set()
+
+ def on_failed(_receipt=None):
+ result["failure_reason"] = "request_failed"
+ done.set()
+
+ def on_progress(_receipt):
+ return
+
+ try:
+ manager.request(
+ dest_hash,
+ aspect,
+ path,
+ link_request_data,
+ response_callback=on_response,
+ failed_callback=on_failed,
+ progress_callback=on_progress,
+ timeout=timeout,
+ )
+ except Exception as exc:
+ return {
+ "ok": False,
+ "failure_reason": f"request_dispatch_failed: {exc}",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ wait_timeout = float(timeout) if timeout is not None else 30.0
+ if not done.wait(timeout=max(wait_timeout, 1.0) + 5.0):
+ result["failure_reason"] = "request_timeout"
+ return result
+
+ def _rns_link_send(self, args: dict[str, Any]) -> dict[str, Any]:
+ import base64
+
+ dest_hash, aspect = self._parse_rns_link_args(args)
+ manager = self._require_rns_link_manager()
+ payload_b64 = args.get("payload_b64", "")
+ try:
+ payload = (
+ base64.b64decode(payload_b64, validate=True) if payload_b64 else b""
+ )
+ except Exception as exc:
+ raise ValueError("invalid payload_b64") from exc
+ ok, failure_reason = manager.send_packet(dest_hash, aspect, payload)
+ return {
+ "ok": ok,
+ "failure_reason": failure_reason,
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ def _rns_link_close(self, args: dict[str, Any]) -> dict[str, Any]:
+ dest_hash, aspect = self._parse_rns_link_args(args)
+ manager = self._require_rns_link_manager()
+ ok = manager.close(dest_hash, aspect)
+ return {
+ "ok": ok,
+ "failure_reason": None if ok else "no_active_link",
+ "destination_hash": dest_hash.hex(),
+ "aspect": aspect,
+ }
+
+ def on_rns_link_event(self, payload: dict[str, Any]) -> None:
+ if not self._plugins_runtime_enabled():
+ return
+ event_payload = {
+ "event": payload.get("event"),
+ "destination_hash": payload.get("destination_hash"),
+ "aspect": payload.get("aspect"),
+ "payload_b64": payload.get("payload_b64"),
+ }
+ for record in list(self._plugins.values()):
+ if record.enabled and self._hook_allowed(record, "rns.link.event"):
+ self.dispatch_hook(record.id, "rns.link.event", event_payload)
+
def _destination_path_read(self, args: dict[str, Any]) -> dict[str, Any]:
if not self.app or not getattr(self.app, "reticulum", None):
return {"paths": [], "total": 0, "responsive": 0, "unresponsive": 0}
diff --git a/meshchatx/src/backend/plugin_permissions.py b/meshchatx/src/backend/plugin_permissions.py
new file mode 100644
index 00000000..7d0d327e
--- /dev/null
+++ b/meshchatx/src/backend/plugin_permissions.py
@@ -0,0 +1,297 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Plugin permission catalog, grant normalization, and network endpoint scanning."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from typing import Any
+
+KNOWN_HOOKS = frozenset(
+ {
+ "announce.received",
+ "rns.link.event",
+ }
+)
+
+KNOWN_MANAGERS = frozenset(
+ {
+ "destinationPath.read",
+ "rnsLink.open",
+ "rnsLink.identify",
+ "rnsLink.request",
+ "rnsLink.send",
+ "rnsLink.close",
+ }
+)
+
+KNOWN_STORAGE = frozenset({"isolated", "none"})
+KNOWN_NETWORK = frozenset({"none", "fetch"})
+
+_URL_IN_TEXT_RE = re.compile(r"""https?://[^\s"'<>\\)\]]+""")
+_SCHEME_HOST_RE = re.compile(
+ r"https?://([a-z0-9][-a-z0-9.]*(?:\.[a-z0-9][-a-z0-9.]*)+)",
+ re.IGNORECASE,
+)
+_SCAN_EXTENSIONS = frozenset({".js", ".mjs", ".json", ".wasm", ".ts", ".go", ".wat"})
+
+
+def permission_id_for_hook(hook: str) -> str:
+ return f"hooks:{hook}"
+
+
+def permission_id_for_manager(manager: str) -> str:
+ return f"managers:{manager}"
+
+
+def permission_id_for_storage(storage: str) -> str:
+ return f"storage:{storage}"
+
+
+def permission_id_for_network(network: str) -> str:
+ return f"network:{network}"
+
+
+def normalize_network_mode(value: Any) -> str:
+ if value is None or value == "" or value == "none":
+ return "none"
+ if value in ("fetch", "http", "https", "outbound"):
+ return "fetch"
+ if isinstance(value, str):
+ return "fetch"
+ return "none"
+
+
+def declared_permission_ids(manifest: dict[str, Any]) -> list[str]:
+ permissions = manifest.get("permissions") or {}
+ if not isinstance(permissions, dict):
+ return []
+ ids: list[str] = []
+ for hook in permissions.get("hooks") or []:
+ if isinstance(hook, str) and hook.strip():
+ ids.append(permission_id_for_hook(hook.strip()))
+ for manager in permissions.get("managers") or []:
+ if isinstance(manager, str) and manager.strip():
+ ids.append(permission_id_for_manager(manager.strip()))
+ storage = permissions.get("storage") or "none"
+ if isinstance(storage, str) and storage not in ("", "none"):
+ ids.append(permission_id_for_storage(storage.strip()))
+ network = normalize_network_mode(permissions.get("network"))
+ if network != "none":
+ ids.append(permission_id_for_network(network))
+ # Deduplicate while preserving order.
+ seen: set[str] = set()
+ ordered: list[str] = []
+ for item in ids:
+ if item in seen:
+ continue
+ seen.add(item)
+ ordered.append(item)
+ return ordered
+
+
+def validate_declared_permissions(manifest: dict[str, Any]) -> None:
+ permissions = manifest.get("permissions") or {}
+ if permissions and not isinstance(permissions, dict):
+ raise ValueError("permissions must be an object")
+ if not isinstance(permissions, dict):
+ return
+ for hook in permissions.get("hooks") or []:
+ if not isinstance(hook, str) or hook not in KNOWN_HOOKS:
+ raise ValueError(f"unknown hook permission: {hook!r}")
+ for manager in permissions.get("managers") or []:
+ if not isinstance(manager, str) or manager not in KNOWN_MANAGERS:
+ raise ValueError(f"unknown manager permission: {manager!r}")
+ storage = permissions.get("storage", "none")
+ if storage is not None and storage not in KNOWN_STORAGE:
+ raise ValueError(f"unknown storage permission: {storage!r}")
+ network = permissions.get("network", "none")
+ if network is not None and normalize_network_mode(network) not in KNOWN_NETWORK:
+ raise ValueError(f"unknown network permission: {network!r}")
+ network_block = manifest.get("network")
+ if network_block is not None:
+ if not isinstance(network_block, dict):
+ raise ValueError("network must be an object")
+ endpoints = network_block.get("endpoints")
+ if endpoints is not None and not isinstance(endpoints, list):
+ raise ValueError("network.endpoints must be an array")
+
+
+def normalize_granted_permissions(
+ declared: list[str], granted: list[str] | None
+) -> list[str]:
+ declared_set = set(declared)
+ if granted is None:
+ return list(declared)
+ selected: list[str] = []
+ seen: set[str] = set()
+ for item in granted:
+ if not isinstance(item, str):
+ continue
+ if item not in declared_set or item in seen:
+ continue
+ seen.add(item)
+ selected.append(item)
+ return selected
+
+
+def granted_allows_hook(granted: list[str] | None, hook: str) -> bool:
+ if granted is None:
+ return True
+ return permission_id_for_hook(hook) in granted
+
+
+def granted_allows_manager(granted: list[str] | None, manager: str) -> bool:
+ if granted is None:
+ return True
+ return permission_id_for_manager(manager) in granted
+
+
+def granted_allows_network_fetch(granted: list[str] | None) -> bool:
+ if granted is None:
+ return True
+ return permission_id_for_network("fetch") in granted
+
+
+def granted_allows_storage(granted: list[str] | None) -> bool:
+ if granted is None:
+ return True
+ return permission_id_for_storage("isolated") in granted
+
+
+def _normalize_endpoint(value: str) -> str:
+ return value.strip().rstrip(".,;)]}\"'")
+
+
+def _is_http_url(value: str) -> bool:
+ lower = value.lower()
+ return lower.startswith("http://") or lower.startswith("https://")
+
+
+def _is_external_http_url(value: str) -> bool:
+ if not _is_http_url(value):
+ return False
+ lower = value.lower()
+ if "localhost" in lower or "127.0.0.1" in lower or "0.0.0.0" in lower:
+ return False
+ if "/_plugins/" in lower or "/api/v1/plugins/" in lower:
+ return False
+ return True
+
+
+def _should_scan_network_file(path: str) -> bool:
+ _, ext = os.path.splitext(path.lower())
+ return ext in _SCAN_EXTENSIONS
+
+
+def extract_urls_from_text(text: str) -> list[str]:
+ seen: set[str] = set()
+ out: list[str] = []
+ for match in _URL_IN_TEXT_RE.findall(text):
+ endpoint = _normalize_endpoint(match)
+ if not endpoint or not _is_external_http_url(endpoint):
+ continue
+ if endpoint in seen:
+ continue
+ seen.add(endpoint)
+ out.append(endpoint)
+ return out
+
+
+def _host_root(endpoint: str) -> str | None:
+ match = _SCHEME_HOST_RE.search(endpoint)
+ if not match:
+ return None
+ return f"https://{match.group(1).lower()}/"
+
+
+def collect_network_endpoints(manifest: dict[str, Any], plugin_dir: str) -> list[str]:
+ seen: set[str] = set()
+ manifest_endpoints: list[str] = []
+ scanned: list[str] = []
+
+ def add(value: str, *, require_http: bool, declared: bool) -> None:
+ value = _normalize_endpoint(value)
+ if not value:
+ return
+ if require_http and not _is_http_url(value):
+ return
+ if require_http and not _is_external_http_url(value):
+ return
+ if value in seen:
+ return
+ seen.add(value)
+ if declared:
+ manifest_endpoints.append(value)
+ else:
+ scanned.append(value)
+
+ network = manifest.get("network") or {}
+ if isinstance(network, dict):
+ for endpoint in network.get("endpoints") or []:
+ if not isinstance(endpoint, str):
+ continue
+ urls = extract_urls_from_text(endpoint)
+ if not urls:
+ add(endpoint, require_http=False, declared=True)
+ continue
+ for url in urls:
+ add(url, require_http=False, declared=True)
+
+ if plugin_dir and os.path.isdir(plugin_dir):
+ for root, _dirs, files in os.walk(plugin_dir):
+ for name in files:
+ path = os.path.join(root, name)
+ if not _should_scan_network_file(path):
+ continue
+ try:
+ with open(path, "rb") as handle:
+ data = handle.read(2_000_000)
+ except OSError:
+ continue
+ try:
+ text = data.decode("utf-8", errors="ignore")
+ except Exception:
+ continue
+ for url in extract_urls_from_text(text):
+ add(url, require_http=True, declared=False)
+
+ for endpoint in list(manifest_endpoints) + list(scanned):
+ root = _host_root(endpoint)
+ if root:
+ add(root, require_http=True, declared=False)
+
+ scanned.sort()
+ return manifest_endpoints + scanned
+
+
+def requires_network_fetch(manifest: dict[str, Any], endpoints: list[str]) -> bool:
+ permissions = manifest.get("permissions") or {}
+ network = normalize_network_mode(
+ permissions.get("network") if isinstance(permissions, dict) else None
+ )
+ if network == "fetch":
+ return True
+ return bool(endpoints)
+
+
+def permission_label_key(permission_id: str) -> str:
+ return f"plugins.permissions.{permission_id.replace(':', '.')}"
+
+
+def serialize_granted(granted: list[str] | None) -> str:
+ return json.dumps(list(granted or []), separators=(",", ":"))
+
+
+def deserialize_granted(raw: str | None) -> list[str] | None:
+ if raw is None or raw == "":
+ return None
+ try:
+ data = json.loads(raw)
+ except Exception:
+ return None
+ if not isinstance(data, list):
+ return None
+ return [item for item in data if isinstance(item, str)]
diff --git a/meshchatx/src/backend/rns_link_manager.py b/meshchatx/src/backend/rns_link_manager.py
new file mode 100644
index 00000000..8873c175
--- /dev/null
+++ b/meshchatx/src/backend/rns_link_manager.py
@@ -0,0 +1,400 @@
+# SPDX-License-Identifier: 0BSD
+
+import asyncio
+import base64
+import threading
+import time
+from collections.abc import Callable
+from typing import Optional
+
+import RNS
+
+from meshchatx.src.backend import reticulum_pathfinding
+
+
+# Cache of established RNS Links keyed by (aspect_str, destination_hash_bytes).
+# Kept separate from nomadnet_downloader.nomadnet_cached_links — the two caches
+# may merge in the future if NomadNet is ported onto this generic Links API.
+rns_cached_links: dict[tuple[str, bytes], "RNS.Link"] = {}
+_rns_links_lock = threading.Lock()
+
+# Per-cache-key count of consecutive RNS.Link request failures. Reset on
+# successful response; cleared whenever the cached link at the key is
+# replaced or evicted. Guarded by _rns_links_lock (same lock as the cache —
+# the two are mutated together).
+_link_failure_counts: dict[tuple[str, bytes], int] = {}
+
+# Number of consecutive request failures on a cached link that triggers a
+# teardown + cache eviction. The next request to the same destination will
+# then go through the full open_link path and re-establish.
+_LINK_RECYCLE_FAILURE_THRESHOLD = 2
+
+# Wait granularity while polling for path / link (seconds).
+_POLL_INTERVAL_S = 0.02
+
+
+def get_cached_active_link(aspect: str, destination_hash: bytes):
+ """Return a cached link if present and ACTIVE; drop stale entries."""
+ key = (aspect, destination_hash)
+ with _rns_links_lock:
+ link = rns_cached_links.get(key)
+ if link is None:
+ return None
+ if link.status is RNS.Link.ACTIVE:
+ return link
+ try:
+ del rns_cached_links[key]
+ except KeyError:
+ pass
+ return None
+
+
+def sweep_stale_links():
+ with _rns_links_lock:
+ stale = [
+ k for k, v in rns_cached_links.items() if v.status is not RNS.Link.ACTIVE
+ ]
+ for k in stale:
+ del rns_cached_links[k]
+ # Drop counter entries whose link is no longer cached so the dict
+ # cannot grow unbounded across link churn.
+ orphans = [k for k in _link_failure_counts if k not in rns_cached_links]
+ for k in orphans:
+ del _link_failure_counts[k]
+
+
+def _cache_link_if_active(aspect: str, destination_hash: bytes, link) -> None:
+ if link is None or link.status is not RNS.Link.ACTIVE:
+ return
+ key = (aspect, destination_hash)
+ with _rns_links_lock:
+ rns_cached_links[key] = link
+ # A freshly cached link starts with a clean failure count, even if
+ # an older link at the same key died with a non-zero count.
+ _link_failure_counts.pop(key, None)
+
+
+def _uncache_link_if_matches(aspect: str, destination_hash: bytes, link) -> None:
+ if link is None:
+ return
+ key = (aspect, destination_hash)
+ with _rns_links_lock:
+ if rns_cached_links.get(key) is link:
+ try:
+ del rns_cached_links[key]
+ except KeyError:
+ pass
+ _link_failure_counts.pop(key, None)
+
+
+def _reset_failure_count(key: tuple[str, bytes]) -> None:
+ with _rns_links_lock:
+ _link_failure_counts.pop(key, None)
+
+
+def _record_failure_and_maybe_recycle(key: tuple[str, bytes]) -> tuple[int, bool]:
+ """Increment the failure counter for `key`.
+
+ If the threshold is reached, pop the cached link, clear the counter,
+ and tear the link down outside the lock (teardown synchronously
+ re-enters via _on_link_closed → _uncache_link_if_matches).
+ Returns (new_count, recycled).
+ """
+ link_to_teardown = None
+ with _rns_links_lock:
+ n = _link_failure_counts.get(key, 0) + 1
+ if n < _LINK_RECYCLE_FAILURE_THRESHOLD:
+ _link_failure_counts[key] = n
+ return n, False
+ link_to_teardown = rns_cached_links.pop(key, None)
+ _link_failure_counts.pop(key, None)
+ if link_to_teardown is not None:
+ try:
+ link_to_teardown.teardown()
+ except Exception as e:
+ print(f"[rns_link_manager] recycle teardown raised: {e}")
+ return n, True
+
+
+def _split_aspect(aspect: str) -> tuple[str, list[str]]:
+ parts = [p for p in aspect.split(".") if p]
+ if not parts:
+ raise ValueError("aspect must be a non-empty dot-separated string")
+ return parts[0], parts[1:]
+
+
+class RnsLinkManager:
+ """Generic RNS Link lifecycle / request / packet helper.
+
+ The web layer wires three callables:
+ - self_identity_getter: returns the local RNS.Identity (or None).
+ - reticulum_getter: returns the MeshChat reticulum-like (used by
+ reticulum_pathfinding.prepare_fresh_path_request).
+ - broadcast_event: called with a JSON-serializable dict; expected to
+ forward to all interested /ws clients.
+ """
+
+ def __init__(
+ self,
+ *,
+ self_identity_getter: Callable[[], Optional["RNS.Identity"]],
+ reticulum_getter: Callable[[], object],
+ broadcast_event: Callable[[dict], None],
+ ):
+ self._get_identity = self_identity_getter
+ self._get_reticulum = reticulum_getter
+ self._broadcast = broadcast_event
+
+ async def open_link(
+ self,
+ destination_hash: bytes,
+ aspect: str,
+ *,
+ auto_identify: bool = False,
+ on_phase: Optional[Callable[[str], None]] = None,
+ path_lookup_timeout: float = 15.0,
+ link_establishment_timeout: float = 15.0,
+ ) -> tuple[Optional["RNS.Link"], bool, Optional[str]]:
+ """Open (or reuse) a Link to (aspect, destination_hash).
+
+ Returns (link, identified, failure_reason). On failure link is None
+ and failure_reason is set; otherwise failure_reason is None.
+ """
+ app_name, sub_aspects = _split_aspect(aspect)
+
+ def _phase(p: str) -> None:
+ if on_phase is not None:
+ try:
+ on_phase(p)
+ except Exception:
+ pass
+
+ cached = get_cached_active_link(aspect, destination_hash)
+ if cached is not None:
+ identified = False
+ if auto_identify:
+ identity = self._get_identity()
+ if identity is None:
+ return None, False, "no_local_identity"
+ _phase("identifying")
+ try:
+ cached.identify(identity)
+ identified = True
+ except Exception as e:
+ return None, False, f"identify_failed: {e}"
+ return cached, identified, None
+
+ # Path lookup
+ reticulum_pathfinding.prepare_fresh_path_request(
+ self._get_reticulum(),
+ destination_hash,
+ )
+ if not RNS.Transport.has_path(destination_hash):
+ _phase("finding_path")
+ deadline = time.time() + path_lookup_timeout
+ try:
+ while (
+ not RNS.Transport.has_path(destination_hash)
+ and time.time() < deadline
+ ):
+ await asyncio.sleep(_POLL_INTERVAL_S)
+ except asyncio.CancelledError:
+ # No link object yet — nothing to tear down. Just propagate.
+ raise
+ if not RNS.Transport.has_path(destination_hash):
+ return None, False, "no_path_to_destination"
+
+ # Re-check cache after path discovery (some other request may have
+ # established a link in parallel).
+ cached = get_cached_active_link(aspect, destination_hash)
+ if cached is not None:
+ identified = False
+ if auto_identify:
+ identity = self._get_identity()
+ if identity is None:
+ return None, False, "no_local_identity"
+ _phase("identifying")
+ try:
+ cached.identify(identity)
+ identified = True
+ except Exception as e:
+ return None, False, f"identify_failed: {e}"
+ return cached, identified, None
+
+ _phase("establishing_link")
+ identity = RNS.Identity.recall(destination_hash)
+ if identity is None:
+ return None, False, "no_identity_for_destination"
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *sub_aspects,
+ )
+
+ link = RNS.Link(destination)
+ link.set_packet_callback(
+ lambda data, packet, _aspect=aspect, _dh=destination_hash: self._on_packet(
+ _aspect,
+ _dh,
+ data,
+ ),
+ )
+ link.set_link_closed_callback(
+ lambda lnk, _aspect=aspect, _dh=destination_hash: self._on_link_closed(
+ _aspect,
+ _dh,
+ lnk,
+ ),
+ )
+
+ deadline = time.time() + link_establishment_timeout
+ try:
+ while link.status is not RNS.Link.ACTIVE and time.time() < deadline:
+ await asyncio.sleep(_POLL_INTERVAL_S)
+ except asyncio.CancelledError:
+ # Caller bailed (typically: WS client disconnected). Tear down the
+ # half-built link so it doesn't sit half-established consuming
+ # RNS bookkeeping for the destination.
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ raise
+ if link.status is not RNS.Link.ACTIVE:
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ return None, False, "link_establishment_timeout"
+
+ _cache_link_if_active(aspect, destination_hash, link)
+
+ identified = False
+ if auto_identify:
+ identity_local = self._get_identity()
+ if identity_local is None:
+ return None, False, "no_local_identity"
+ _phase("identifying")
+ try:
+ link.identify(identity_local)
+ identified = True
+ except Exception as e:
+ return None, False, f"identify_failed: {e}"
+
+ return link, identified, None
+
+ def identify(
+ self, destination_hash: bytes, aspect: str
+ ) -> tuple[bool, Optional[str]]:
+ link = get_cached_active_link(aspect, destination_hash)
+ if link is None:
+ return False, "no_active_link"
+ identity = self._get_identity()
+ if identity is None:
+ return False, "no_local_identity"
+ try:
+ link.identify(identity)
+ except Exception as e:
+ return False, f"identify_failed: {e}"
+ return True, None
+
+ def request(
+ self,
+ destination_hash: bytes,
+ aspect: str,
+ path: str,
+ data,
+ response_callback,
+ failed_callback,
+ progress_callback,
+ timeout: Optional[float] = None,
+ ):
+ link = get_cached_active_link(aspect, destination_hash)
+ if link is None:
+ raise RuntimeError("no_active_link")
+
+ key = (aspect, destination_hash)
+
+ def _wrapped_response(receipt, _cb=response_callback, _key=key):
+ _reset_failure_count(_key)
+ _cb(receipt)
+
+ def _wrapped_failed(receipt=None, _cb=failed_callback, _key=key):
+ _count, recycled = _record_failure_and_maybe_recycle(_key)
+ if recycled:
+ # The cached link has been torn down and evicted; the next
+ # rns.link.request to this destination will re-establish.
+ # The existing link_closed event already fires from
+ # _on_link_closed via link.teardown(), so clients that watch
+ # for it can react.
+ #
+ # Future enhancement (option B from design): broadcast a
+ # dedicated rns.link.event with
+ # event="link_recycled_after_failures", failures=_count,
+ # destination_hash=_key[1].hex(), aspect=_key[0] — useful
+ # for UIs that want to surface "link reset after N failures"
+ # diagnostics distinct from a plain link_closed.
+ pass
+ _cb(receipt)
+
+ return link.request(
+ path,
+ data=data,
+ response_callback=_wrapped_response,
+ failed_callback=_wrapped_failed,
+ progress_callback=progress_callback,
+ timeout=timeout,
+ )
+
+ def send_packet(
+ self, destination_hash: bytes, aspect: str, payload: bytes
+ ) -> tuple[bool, Optional[str]]:
+ link = get_cached_active_link(aspect, destination_hash)
+ if link is None:
+ return False, "no_active_link"
+ try:
+ RNS.Packet(link, payload).send()
+ except Exception as e:
+ return False, f"send_failed: {e}"
+ return True, None
+
+ def close(self, destination_hash: bytes, aspect: str) -> bool:
+ link = get_cached_active_link(aspect, destination_hash)
+ if link is None:
+ return False
+ _uncache_link_if_matches(aspect, destination_hash, link)
+ try:
+ link.teardown()
+ except Exception as e:
+ print(f"[rns_link_manager] close teardown raised: {e}")
+ return True
+
+ def _on_packet(self, aspect: str, destination_hash: bytes, data: bytes) -> None:
+ try:
+ self._broadcast(
+ {
+ "type": "rns.link.event",
+ "event": "packet_received",
+ "destination_hash": destination_hash.hex(),
+ "aspect": aspect,
+ "payload_b64": base64.b64encode(bytes(data)).decode("ascii"),
+ }
+ )
+ except Exception:
+ pass
+
+ def _on_link_closed(self, aspect: str, destination_hash: bytes, link) -> None:
+ _uncache_link_if_matches(aspect, destination_hash, link)
+ try:
+ self._broadcast(
+ {
+ "type": "rns.link.event",
+ "event": "link_closed",
+ "destination_hash": destination_hash.hex(),
+ "aspect": aspect,
+ }
+ )
+ except Exception:
+ pass
diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index 9d087759..6cebe75d 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -63,6 +63,7 @@ SELF_CHECK_LABELS = {
"http_favourites_good": "HTTP Favourites ",
"http_telephone_good": "HTTP Telephone Status ",
"websocket_good": "WebSocket /ws ",
+ "websocket_rns_link_good": "WebSocket RNS Link API ",
"bots_lifecycle": "Bot Create/Start/Stop ",
}
@@ -674,9 +675,66 @@ _WEB_PROBE_KEYS = (
"http_favourites_good",
"http_telephone_good",
"websocket_good",
+ "websocket_rns_link_good",
)
+async def _probe_rns_link_api(ws: Any, *, timeout: float = 10.0) -> dict[str, str]:
+ """Exercise generic rns.link.* handlers without requiring a live mesh peer."""
+ import asyncio
+ import json
+
+ from aiohttp import WSMsgType
+
+ request_id = "self-check-rns-link"
+ try:
+ await ws.send_str(
+ json.dumps(
+ {
+ "type": "rns.link.close",
+ "destination_hash": "aa" * 16,
+ "aspect": "meshchatx.selfcheck",
+ "request_id": request_id,
+ }
+ )
+ )
+ deadline = asyncio.get_event_loop().time() + timeout
+ while True:
+ remaining = deadline - asyncio.get_event_loop().time()
+ if remaining <= 0:
+ return _status(False, "rns.link.close reply timed out")
+ msg = await asyncio.wait_for(ws.receive(), timeout=remaining)
+ if msg.type != WSMsgType.TEXT:
+ continue
+ try:
+ payload = json.loads(msg.data)
+ except Exception:
+ continue
+ if not isinstance(payload, dict):
+ continue
+ if payload.get("type") != "rns.link.close":
+ continue
+ if payload.get("request_id") != request_id:
+ continue
+ status = payload.get("status")
+ if status not in ("success", "failure"):
+ return _status(False, f"unexpected status={status!r}")
+ # No cached link is expected in self-check; failure is the normal path.
+ if status == "failure" and payload.get("failure_reason") not in (
+ None,
+ "no_active_link",
+ ):
+ return _status(
+ False,
+ f"unexpected failure_reason={payload.get('failure_reason')!r}",
+ )
+ return _status(True)
+ except TimeoutError:
+ return _status(False, "rns.link.close reply timed out")
+ except Exception as exc:
+ return _status(False, str(exc))
+
+
async def _probe_json_get(
client: Any,
path: str,
@@ -870,10 +928,21 @@ async def _run_web_api_probes(app: Any) -> dict[str, dict[str, str]]:
)
else:
results["websocket_good"] = _status(True)
+
+ if results["websocket_good"]["status"] == "ok":
+ results["websocket_rns_link_good"] = await _probe_rns_link_api(
+ ws
+ )
+ else:
+ results["websocket_rns_link_good"] = _status(
+ False,
+ "skipped: websocket_good failed",
+ )
finally:
await ws.close()
except Exception as exc:
results["websocket_good"] = _status(False, str(exc))
+ results["websocket_rns_link_good"] = _status(False, str(exc))
except Exception as exc:
failed = _status(False, f"Web probe client failed: {exc}")
for key in results:
diff --git a/meshchatx/src/backend/websocket_config_guard.py b/meshchatx/src/backend/websocket_config_guard.py
index 232a5c45..b1014a6e 100644
--- a/meshchatx/src/backend/websocket_config_guard.py
+++ b/meshchatx/src/backend/websocket_config_guard.py
@@ -49,6 +49,11 @@ WEBSOCKET_MUTATOR_TYPES = frozenset(
"nomadnet.page.archive.add",
"nomadnet.page.archive.flush",
"nomadnet.page.download",
+ "rns.link.close",
+ "rns.link.identify",
+ "rns.link.open",
+ "rns.link.request",
+ "rns.link.send",
},
)
diff --git a/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue
index 7335f6d0..4b18e2f6 100644
--- a/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue
+++ b/meshchatx/src/frontend/components/settings/NotificationSoundSettings.vue
@@ -10,19 +10,26 @@
</div>
</header>
<div class="settings-section__body space-y-4">
- <label class="setting-toggle">
- <Toggle
- id="notification-sound-enabled"
- :model-value="config.notification_sound_enabled"
- @update:model-value="onEnabledChange"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{ $t("app.enable_notification_sound") }}</span>
- <span class="setting-toggle__description">{{
- $t("app.enable_notification_sound_description")
- }}</span>
- </span>
- </label>
+ <div
+ class="rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3"
+ >
+ <div class="flex items-start justify-between gap-3">
+ <div class="min-w-0 flex-1 space-y-1">
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">
+ {{ $t("app.enable_notification_sound") }}
+ </div>
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
+ {{ $t("app.enable_notification_sound_description") }}
+ </p>
+ </div>
+ <Toggle
+ id="notification-sound-enabled"
+ class="shrink-0 mt-0.5"
+ :model-value="config.notification_sound_enabled"
+ @update:model-value="onEnabledChange"
+ />
+ </div>
+ </div>
<div v-if="config.notification_sound_enabled" class="space-y-4">
<div>
diff --git a/meshchatx/src/frontend/components/settings/PluginInstallDialog.vue b/meshchatx/src/frontend/components/settings/PluginInstallDialog.vue
new file mode 100644
index 00000000..d5d81204
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/PluginInstallDialog.vue
@@ -0,0 +1,170 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="open && preview" class="fixed inset-0 z-50 flex items-center justify-center p-4" role="presentation">
+ <button
+ type="button"
+ class="absolute inset-0 bg-black/50"
+ :aria-label="$t('plugins.install_dialog.close')"
+ :disabled="confirming"
+ @click="onCancel"
+ />
+ <div
+ class="relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl p-5 space-y-4"
+ role="alertdialog"
+ aria-modal="true"
+ aria-labelledby="plugin-install-title"
+ >
+ <h2 id="plugin-install-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100">
+ {{
+ preview.requires_network_fetch
+ ? $t("plugins.install_dialog.network_title")
+ : $t("plugins.install_dialog.title")
+ }}
+ </h2>
+ <p class="text-sm text-gray-600 dark:text-gray-400">
+ {{ $t("plugins.install_dialog.message", { name: preview.name, id: preview.id }) }}
+ </p>
+
+ <div class="space-y-1">
+ <p class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ preview.name }}
+ <span class="text-xs font-normal text-gray-500">v{{ preview.version }}</span>
+ </p>
+ <p v-if="preview.description" class="text-sm text-gray-600 dark:text-gray-400">
+ {{ preview.description }}
+ </p>
+ </div>
+
+ <section v-if="(preview.permissions || []).length" class="space-y-2">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
+ {{ $t("plugins.install_dialog.permissions") }}
+ </h3>
+ <p class="text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("plugins.install_dialog.permissions_hint") }}
+ </p>
+ <ul class="space-y-2">
+ <li
+ v-for="perm in preview.permissions"
+ :key="perm"
+ class="flex items-center justify-between gap-3 rounded-md border border-gray-200 dark:border-zinc-700 px-3 py-2"
+ >
+ <span class="text-sm text-gray-800 dark:text-gray-200">{{ labelFor(perm) }}</span>
+ <label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
+ <input v-model="grantedMap[perm]" type="checkbox" class="rounded border-gray-300" />
+ {{ $t("plugins.install_dialog.grant") }}
+ </label>
+ </li>
+ </ul>
+ </section>
+
+ <section v-if="preview.requires_network_fetch" class="space-y-2">
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
+ {{ $t("plugins.install_dialog.network_endpoints") }}
+ </h3>
+ <p v-if="!networkFetchGranted" class="text-xs text-amber-700 dark:text-amber-300">
+ {{ $t("plugins.install_dialog.network_endpoints_blocked") }}
+ </p>
+ <ul
+ v-if="(preview.network_endpoints || []).length"
+ class="space-y-1 rounded-md border border-gray-200 dark:border-zinc-700 p-3"
+ >
+ <li
+ v-for="endpoint in preview.network_endpoints"
+ :key="endpoint"
+ class="text-xs font-mono break-all text-gray-700 dark:text-gray-300"
+ >
+ {{ endpoint }}
+ </li>
+ </ul>
+ <p v-else class="text-xs text-gray-500 dark:text-gray-400">
+ {{ $t("plugins.install_dialog.network_endpoints_unknown") }}
+ </p>
+ </section>
+
+ <div class="flex justify-end gap-2 pt-2">
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-md border border-gray-300 dark:border-zinc-600 text-sm"
+ :disabled="confirming"
+ @click="onCancel"
+ >
+ {{ $t("plugins.install_dialog.cancel") }}
+ </button>
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm"
+ :disabled="confirming"
+ @click="confirm"
+ >
+ {{ confirming ? $t("plugins.settings.installing") : $t("plugins.install_dialog.confirm") }}
+ </button>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { permissionLabel } from "../../js/plugins/pluginPermissions.js";
+
+export default {
+ name: "PluginInstallDialog",
+ props: {
+ open: { type: Boolean, default: false },
+ preview: { type: Object, default: null },
+ confirming: { type: Boolean, default: false },
+ },
+ emits: ["confirm", "cancel"],
+ data() {
+ return {
+ grantedMap: {},
+ };
+ },
+ computed: {
+ networkFetchGranted() {
+ return this.grantedMap["network:fetch"] === true;
+ },
+ },
+ watch: {
+ open: {
+ immediate: true,
+ handler(value) {
+ if (value) {
+ this.resetGrants();
+ }
+ },
+ },
+ preview: {
+ immediate: true,
+ handler() {
+ this.resetGrants();
+ },
+ },
+ },
+ methods: {
+ resetGrants() {
+ const next = {};
+ for (const perm of this.preview?.permissions || []) {
+ next[perm] = true;
+ }
+ this.grantedMap = next;
+ },
+ labelFor(perm) {
+ return permissionLabel(perm, (key) => this.$t(key));
+ },
+ selectedPermissions() {
+ return (this.preview?.permissions || []).filter((perm) => this.grantedMap[perm]);
+ },
+ onCancel() {
+ if (!this.confirming) {
+ this.$emit("cancel");
+ }
+ },
+ confirm() {
+ this.$emit("confirm", {
+ grantedPermissions: this.selectedPermissions(),
+ });
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
index eb8a5643..4e8cd34b 100644
--- a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
@@ -27,14 +27,18 @@
type="file"
accept=".zip,application/zip"
class="sr-only"
- :disabled="installing"
+ :disabled="installing || previewing"
@change="onInstallFile"
/>
<span
class="px-4 py-2 rounded-md bg-blue-600 text-white text-sm cursor-pointer hover:bg-blue-700"
- :class="installing ? 'opacity-60 pointer-events-none' : ''"
+ :class="installing || previewing ? 'opacity-60 pointer-events-none' : ''"
>
- {{ installing ? $t("plugins.settings.installing") : $t("plugins.settings.choose_file") }}
+ {{
+ installing || previewing
+ ? $t("plugins.settings.installing")
+ : $t("plugins.settings.choose_file")
+ }}
</span>
</label>
</div>
@@ -81,6 +85,12 @@
>
{{ $t("plugins.settings.badge_wasm") }}
</span>
+ <span
+ v-if="plugin.requires_network_fetch"
+ class="px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
+ >
+ {{ $t("plugins.settings.badge_network") }}
+ </span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ plugin.description }}</p>
<p class="text-xs text-gray-500 dark:text-gray-500">{{ plugin.id }} · v{{ plugin.version }}</p>
@@ -120,24 +130,48 @@
<li v-for="line in permissionLines(plugin)" :key="line">{{ line }}</li>
</ul>
</div>
+ <div
+ v-if="(plugin.network_endpoints || []).length"
+ class="text-sm text-gray-700 dark:text-gray-300 space-y-1"
+ >
+ <p class="font-medium">{{ $t("plugins.settings.network_endpoints") }}</p>
+ <ul class="list-disc pl-5">
+ <li
+ v-for="endpoint in plugin.network_endpoints"
+ :key="endpoint"
+ class="font-mono text-xs break-all"
+ >
+ {{ endpoint }}
+ </li>
+ </ul>
+ </div>
<p v-if="plugin.auto_disabled_reason" class="text-sm text-amber-700 dark:text-amber-300">
{{ $t("plugins.settings.auto_disabled", { reason: plugin.auto_disabled_reason }) }}
</p>
</div>
</div>
+
+ <PluginInstallDialog
+ :open="dialogOpen"
+ :preview="installPreview"
+ :confirming="installing"
+ @cancel="cancelInstallPreview"
+ @confirm="confirmInstallPreview"
+ />
</SettingsSectionBlock>
</template>
<script>
import SettingsSectionBlock from "./SettingsSectionBlock.vue";
+import PluginInstallDialog from "./PluginInstallDialog.vue";
import ToastUtils from "../../js/ToastUtils";
-import { manifestPermissionSummary } from "../../js/plugins/pluginManifest.js";
+import { permissionLabel } from "../../js/plugins/pluginPermissions.js";
import { pluginHost } from "../../js/plugins/PluginHost.js";
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
export default {
name: "PluginsSettingsSection",
- components: { SettingsSectionBlock },
+ components: { SettingsSectionBlock, PluginInstallDialog },
props: {
visible: {
type: Boolean,
@@ -149,7 +183,11 @@ export default {
plugins: [],
dragActive: false,
installing: false,
+ previewing: false,
busyPluginId: null,
+ dialogOpen: false,
+ installPreview: null,
+ pendingArchive: null,
};
},
mounted() {
@@ -171,7 +209,11 @@ export default {
return this.$i18n?.locale?.value || this.$i18n?.locale || "en";
},
permissionLines(plugin) {
- return manifestPermissionSummary(plugin.manifest || { permissions: plugin.permissions || {} });
+ const granted = plugin.granted_permissions || plugin.declared_permissions || [];
+ if (!granted.length) {
+ return [this.$t("plugins.permissions.none")];
+ }
+ return granted.map((id) => permissionLabel(id, (key) => this.$t(key)));
},
async refresh() {
const response = await window.api.get("/api/v1/plugins");
@@ -222,37 +264,67 @@ export default {
this.busyPluginId = null;
}
},
- async installArchive(file) {
+ async beginInstallPreview(file) {
if (!file) {
return;
}
- this.installing = true;
+ this.previewing = true;
+ this.pendingArchive = file;
try {
const formData = new FormData();
formData.append("archive", file);
- await window.api.post("/api/v1/plugins/install", formData);
- await this.refresh();
- ToastUtils.success(this.$t("plugins.settings.installed"));
+ const response = await window.api.post("/api/v1/plugins/preview", formData);
+ this.installPreview = response.data;
+ this.dialogOpen = true;
} catch (error) {
+ this.pendingArchive = null;
+ this.installPreview = null;
ToastUtils.error(
this.$t("plugins.settings.install_failed", { reason: error?.message || String(error) })
);
} finally {
- this.installing = false;
+ this.previewing = false;
this.dragActive = false;
if (this.$refs.fileInput) {
this.$refs.fileInput.value = "";
}
}
},
+ cancelInstallPreview() {
+ this.dialogOpen = false;
+ this.installPreview = null;
+ this.pendingArchive = null;
+ },
+ async confirmInstallPreview({ grantedPermissions }) {
+ if (!this.pendingArchive) {
+ this.cancelInstallPreview();
+ return;
+ }
+ this.installing = true;
+ try {
+ const formData = new FormData();
+ formData.append("archive", this.pendingArchive);
+ formData.append("granted_permissions", JSON.stringify(grantedPermissions || []));
+ await window.api.post("/api/v1/plugins/install", formData);
+ await this.refresh();
+ ToastUtils.success(this.$t("plugins.settings.installed"));
+ this.cancelInstallPreview();
+ } catch (error) {
+ ToastUtils.error(
+ this.$t("plugins.settings.install_failed", { reason: error?.message || String(error) })
+ );
+ } finally {
+ this.installing = false;
+ }
+ },
async onInstallFile(event) {
const file = event.target.files?.[0];
- await this.installArchive(file);
+ await this.beginInstallPreview(file);
},
async onDropArchive(event) {
this.dragActive = false;
const file = event.dataTransfer?.files?.[0];
- await this.installArchive(file);
+ await this.beginInstallPreview(file);
},
},
};
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index baaad073..e1d46614 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -723,31 +723,59 @@
: 'border-red-200/60 dark:border-red-900/30'
"
>
- <div class="flex items-center justify-between">
- <div class="flex items-center gap-2 font-semibold text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2 font-semibold text-sm min-w-0">
<MaterialDesignIcon
:icon-name="
check.passed ? 'check-circle-outline' : 'alert-circle-outline'
"
:class="check.passed ? 'text-emerald-500' : 'text-red-500'"
- class="size-4"
+ class="size-4 shrink-0"
/>
- <span>{{ check.label }}</span>
+ <span class="truncate">{{ check.label }}</span>
+ </div>
+ <div class="flex items-center gap-1.5 shrink-0">
+ <button
+ v-if="!check.passed && check.reason"
+ type="button"
+ class="inline-flex items-center justify-center rounded-lg p-1 text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-950/40"
+ :aria-expanded="isSelfTestReasonExpanded(check.key)"
+ :aria-label="
+ isSelfTestReasonExpanded(check.key)
+ ? $t('selftest.collapse_reason')
+ : $t('selftest.expand_reason')
+ "
+ :title="
+ isSelfTestReasonExpanded(check.key)
+ ? $t('selftest.collapse_reason')
+ : $t('selftest.expand_reason')
+ "
+ @click="toggleSelfTestReason(check.key)"
+ >
+ <MaterialDesignIcon
+ :icon-name="
+ isSelfTestReasonExpanded(check.key)
+ ? 'chevron-up'
+ : 'chevron-down'
+ "
+ class="size-4"
+ />
+ </button>
+ <span
+ class="px-2 py-0.5 text-xs font-bold rounded-md"
+ :class="
+ check.passed
+ ? 'bg-emerald-50 dark:bg-emerald-950/20 text-emerald-700 dark:text-emerald-300'
+ : 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-300'
+ "
+ >
+ {{ check.passed ? $t("selftest.passed") : $t("selftest.failed") }}
+ </span>
</div>
- <span
- class="px-2 py-0.5 text-xs font-bold rounded-md"
- :class="
- check.passed
- ? 'bg-emerald-50 dark:bg-emerald-950/20 text-emerald-700 dark:text-emerald-300'
- : 'bg-red-50 dark:bg-red-950/20 text-red-700 dark:text-red-300'
- "
- >
- {{ check.passed ? $t("selftest.passed") : $t("selftest.failed") }}
- </span>
</div>
<div
- v-if="!check.passed && check.reason"
- class="text-xs text-red-600 dark:text-red-400 mt-2 pl-6"
+ v-if="!check.passed && check.reason && isSelfTestReasonExpanded(check.key)"
+ class="text-xs text-red-600 dark:text-red-400 mt-2 pl-6 whitespace-pre-wrap break-words"
>
<span class="font-semibold">{{ $t("selftest.reason_label") }}:</span>
{{ check.reason }}
@@ -2076,11 +2104,28 @@
>
{{ $t("app.connected_to_shared_instance") }}
</p>
- <pre
- class="text-xs font-mono whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-200 bg-white/60 dark:bg-zinc-900/60 rounded-lg p-2 border border-gray-200/70 dark:border-zinc-800"
- >{{
- reticulumInstance.rpc_config_snippet || $t("app.rpc_config_unavailable")
- }}</pre>
+ <div
+ class="relative rounded-lg border border-gray-200/70 dark:border-zinc-800 bg-white/60 dark:bg-zinc-900/60"
+ >
+ <pre
+ class="text-xs font-mono whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-200 p-2 pr-12"
+ >{{ displayedRpcConfigSnippet }}</pre>
+ <button
+ v-if="reticulumInstance.rpc_config_snippet"
+ type="button"
+ class="absolute top-1.5 right-1.5 inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 hover:text-gray-800 hover:bg-gray-100 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800"
+ :aria-label="
+ rpcKeyVisible ? $t('app.rpc_key_hide') : $t('app.rpc_key_show')
+ "
+ :title="rpcKeyVisible ? $t('app.rpc_key_hide') : $t('app.rpc_key_show')"
+ @click="rpcKeyVisible = !rpcKeyVisible"
+ >
+ <MaterialDesignIcon
+ :icon-name="rpcKeyVisible ? 'eye-off-outline' : 'eye-outline'"
+ class="w-4 h-4"
+ />
+ </button>
+ </div>
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-semibold px-3 py-2"
@@ -2104,21 +2149,41 @@
</div>
</header>
<div class="settings-section__body space-y-3">
- <label class="setting-toggle">
- <Toggle
- id="show-community-interfaces"
- v-model="config.show_suggested_community_interfaces"
- @update:model-value="onShowSuggestedCommunityInterfacesChangeWrapper"
- />
- <span class="setting-toggle__label">
- <span class="setting-toggle__title">{{
- $t("app.show_community_interfaces")
- }}</span>
- <span class="setting-toggle__description">{{
- $t("app.community_interfaces_description")
- }}</span>
- </span>
- </label>
+ <div
+ class="flex items-start gap-2 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3"
+ >
+ <label
+ class="setting-toggle flex-1 min-w-0 !border-0 !bg-transparent !p-0 !rounded-none"
+ >
+ <Toggle
+ id="show-community-interfaces"
+ v-model="config.show_suggested_community_interfaces"
+ @update:model-value="onShowSuggestedCommunityInterfacesChangeWrapper"
+ />
+ <span class="setting-toggle__label">
+ <span class="setting-toggle__title">{{
+ $t("app.show_community_interfaces")
+ }}</span>
+ <span class="setting-toggle__description">{{
+ $t("app.community_interfaces_description")
+ }}</span>
+ </span>
+ </label>
+ <button
+ type="button"
+ class="shrink-0 inline-flex items-center justify-center rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2.5 text-gray-700 hover:bg-gray-50 dark:text-zinc-200 dark:hover:bg-zinc-800 disabled:opacity-50"
+ :disabled="refreshingCommunityInterfaces"
+ :aria-label="$t('app.refresh_community_interfaces')"
+ :title="$t('app.refresh_community_interfaces')"
+ @click="refreshCommunityInterfacesFromSettings"
+ >
+ <MaterialDesignIcon
+ icon-name="refresh"
+ class="w-4 h-4"
+ :class="{ 'animate-spin': refreshingCommunityInterfaces }"
+ />
+ </button>
+ </div>
</div>
</section>
@@ -3199,6 +3264,9 @@ export default {
visualiserShowDiscoveredInterfaces: false,
selfTestRunning: false,
selfTestResults: null,
+ selfTestExpandedReasons: {},
+ rpcKeyVisible: false,
+ refreshingCommunityInterfaces: false,
desktopCloseSettings: {
closeBehavior: "ask",
trayEnabled: true,
@@ -3286,6 +3354,26 @@ export default {
allSelfTestChecksPassed() {
return this.selfTestChecks.length > 0 && this.selfTestChecks.every((check) => check.passed);
},
+ displayedRpcConfigSnippet() {
+ const snippet = this.reticulumInstance?.rpc_config_snippet;
+ if (!snippet) {
+ return this.$t("app.rpc_config_unavailable");
+ }
+ if (this.rpcKeyVisible) {
+ return snippet;
+ }
+ return snippet
+ .split("\n")
+ .map((line) => {
+ const match = line.match(/^(\s*rpc_key\s*=\s*)(.*)$/i);
+ if (!match) {
+ return line;
+ }
+ const value = match[2] || "";
+ return `${match[1]}${"•".repeat(Math.max(8, Math.min(value.length, 48)))}`;
+ })
+ .join("\n");
+ },
safeConfig() {
if (!this.config) {
return {
@@ -3418,6 +3506,32 @@ export default {
ToastUtils.error(this.$t("app.copy_failed"));
}
},
+ isSelfTestReasonExpanded(key) {
+ return !!this.selfTestExpandedReasons?.[key];
+ },
+ toggleSelfTestReason(key) {
+ this.selfTestExpandedReasons = {
+ ...this.selfTestExpandedReasons,
+ [key]: !this.selfTestExpandedReasons?.[key],
+ };
+ },
+ async refreshCommunityInterfacesFromSettings() {
+ if (this.refreshingCommunityInterfaces) {
+ return;
+ }
+ this.refreshingCommunityInterfaces = true;
+ try {
+ const r = await window.api.post("/api/v1/community-interfaces/refresh", {});
+ const n = r.data?.count ?? 0;
+ ToastUtils.success(this.$t("interfaces.community_presets_refreshed", { count: n }));
+ } catch (e) {
+ const msg = e.response?.data?.message || this.$t("interfaces.community_presets_refresh_failed");
+ ToastUtils.error(msg);
+ console.error(e);
+ } finally {
+ this.refreshingCommunityInterfaces = false;
+ }
+ },
async loadDesktopCloseSettings() {
if (!ElectronUtils.isElectron()) {
return;
@@ -3473,6 +3587,7 @@ export default {
}
this.selfTestRunning = true;
this.selfTestResults = null;
+ this.selfTestExpandedReasons = {};
try {
const response = await window.api.get("/api/v1/self-test");
this.selfTestResults = response.data;
diff --git a/meshchatx/src/frontend/index.html b/meshchatx/src/frontend/index.html
index 97549272..bc1622f7 100644
--- a/meshchatx/src/frontend/index.html
+++ b/meshchatx/src/frontend/index.html
@@ -37,10 +37,28 @@
background: #450a0a;
color: #fecaca;
}
+ #meshchatx-boot-splash .meshchatx-boot-logo-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 4.5rem;
+ height: 4.5rem;
+ padding: 0.4rem;
+ border-radius: 1rem;
+ background: rgba(255, 255, 255, 0.72);
+ box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
+ }
+ @media (prefers-color-scheme: dark) {
+ #meshchatx-boot-splash .meshchatx-boot-logo-wrap {
+ background: rgba(24, 24, 27, 0.85);
+ box-shadow: inset 0 0 0 1px rgba(244, 244, 245, 0.12);
+ }
+ }
#meshchatx-boot-splash .meshchatx-boot-logo {
width: 3.25rem;
height: 3.25rem;
object-fit: contain;
+ display: block;
}
#meshchatx-boot-splash .meshchatx-boot-title {
font-weight: 600;
@@ -80,9 +98,11 @@
</div>
</noscript>
<div id="meshchatx-boot-splash" role="status" aria-live="polite" aria-busy="true">
- <img class="meshchatx-boot-logo" src="favicons/favicon-512x512.png" alt="" width="52" height="52" />
+ <div class="meshchatx-boot-logo-wrap">
+ <img class="meshchatx-boot-logo" src="favicons/favicon-512x512.png" alt="" width="52" height="52" />
+ </div>
<div class="meshchatx-boot-title">MeshChatX</div>
- <div class="meshchatx-boot-line" data-boot-line>Starting…</div>
+ <div class="meshchatx-boot-line" data-boot-line>Getting things ready…</div>
</div>
<div id="app"></div>
<script type="module" src="main.js"></script>
diff --git a/meshchatx/src/frontend/js/networkStartupWait.js b/meshchatx/src/frontend/js/networkStartupWait.js
new file mode 100644
index 00000000..c3696c85
--- /dev/null
+++ b/meshchatx/src/frontend/js/networkStartupWait.js
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+export const STARTUP_STAGE_LABELS = {
+ http: "Getting things ready…",
+ starting: "Getting the mesh ready…",
+ rns: "Connecting to the mesh…",
+ identity: "Almost there…",
+ ready: "Ready",
+ failed: "Startup failed",
+};
+
+/**
+ * Interpret a /api/v1/status JSON body for boot gating.
+ * @param {unknown} data
+ * @returns {{ kind: "ready" | "failed" | "starting" | "invalid", stage?: string, error?: string, label?: string }}
+ */
+export function interpretStartupStatus(data) {
+ if (!data || typeof data !== "object") {
+ return { kind: "invalid" };
+ }
+ const status = data.status;
+ const stage = typeof data.stage === "string" ? data.stage : undefined;
+ if (status === "failed") {
+ return {
+ kind: "failed",
+ stage: stage || "failed",
+ error: typeof data.error === "string" ? data.error : undefined,
+ };
+ }
+ if (status === "ok" || data.network_ready === true) {
+ return { kind: "ready", stage: stage || "ready" };
+ }
+ if (status === "starting" || status === undefined) {
+ const resolvedStage = stage || "starting";
+ return {
+ kind: "starting",
+ stage: resolvedStage,
+ label: STARTUP_STAGE_LABELS[resolvedStage] || "Starting network…",
+ };
+ }
+ return { kind: "invalid", stage };
+}
+
+/**
+ * Poll /api/v1/status until the network stack is ready.
+ * @param {{
+ * fetchImpl?: typeof fetch,
+ * now?: () => number,
+ * sleep?: (ms: number) => Promise<void>,
+ * timeoutMs?: number,
+ * onLine?: (text: string) => void,
+ * onErrorState?: () => void,
+ * statusUrl?: string,
+ * }} [options]
+ * @returns {Promise<boolean>}
+ */
+export async function waitForNetworkReady(options = {}) {
+ const fetchImpl = options.fetchImpl || fetch;
+ const now = options.now || Date.now;
+ const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
+ const timeoutMs = options.timeoutMs ?? 120000;
+ const onLine = options.onLine || (() => {});
+ const onErrorState = options.onErrorState || (() => {});
+ const statusUrl = options.statusUrl || "/api/v1/status";
+
+ const deadline = now() + timeoutMs;
+ let delayMs = 200;
+ while (now() < deadline) {
+ try {
+ const response = await fetchImpl(statusUrl, { cache: "no-store" });
+ if (response.ok) {
+ const data = await response.json();
+ const interpreted = interpretStartupStatus(data);
+ if (interpreted.kind === "failed") {
+ onLine(interpreted.error || "Network startup failed.");
+ onErrorState();
+ return false;
+ }
+ if (interpreted.kind === "ready") {
+ return true;
+ }
+ if (interpreted.kind === "starting") {
+ onLine(interpreted.label || "Getting things ready…");
+ }
+ }
+ } catch {
+ onLine("Still starting…");
+ }
+ await sleep(delayMs);
+ delayMs = Math.min(delayMs + 100, 1000);
+ }
+ onLine("Network startup timed out. Try reloading.");
+ onErrorState();
+ return false;
+}
diff --git a/meshchatx/src/frontend/js/plugins/pluginManifest.js b/meshchatx/src/frontend/js/plugins/pluginManifest.js
index a8c46364..6fac9608 100644
--- a/meshchatx/src/frontend/js/plugins/pluginManifest.js
+++ b/meshchatx/src/frontend/js/plugins/pluginManifest.js
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: 0BSD
+import { declaredPermissionIds, manifestPermissionSummary as summarizePermissions } from "./pluginPermissions.js";
+
const SUPPORTED_API_VERSION = 1;
/**
@@ -13,6 +15,7 @@ const SUPPORTED_API_VERSION = 1;
* @property {{ entry: string, type: 'wasm' }} [backend]
* @property {Object} [contributes]
* @property {Object} [permissions]
+ * @property {{ endpoints?: string[] }} [network]
*/
/**
@@ -56,32 +59,20 @@ export function validatePluginManifest(manifest) {
if (permissions && typeof permissions !== "object") {
throw new Error("Plugin permissions must be an object");
}
+ if (record.network != null && typeof record.network !== "object") {
+ throw new Error("Plugin network must be an object");
+ }
return /** @type {PluginManifest} */ (manifest);
}
/**
* @param {PluginManifest} manifest
+ * @param {(key: string, values?: Record<string, unknown>) => string} [t]
* @returns {string[]}
*/
-export function manifestPermissionSummary(manifest) {
- const permissions = manifest.permissions ?? {};
- const lines = [];
- if (Array.isArray(permissions.hooks) && permissions.hooks.length > 0) {
- lines.push(`Hooks: ${permissions.hooks.join(", ")}`);
- }
- if (Array.isArray(permissions.managers) && permissions.managers.length > 0) {
- lines.push(`Managers: ${permissions.managers.join(", ")}`);
- }
- if (permissions.storage === "isolated") {
- lines.push("Isolated plugin storage");
- }
- if (permissions.network && permissions.network !== "none") {
- lines.push(`Network: ${permissions.network}`);
- }
- if (lines.length === 0) {
- lines.push("No elevated permissions");
- }
- return lines;
+export function manifestPermissionSummary(manifest, t) {
+ const translate = t || ((key) => key);
+ return summarizePermissions(manifest, translate);
}
-export { SUPPORTED_API_VERSION };
+export { SUPPORTED_API_VERSION, declaredPermissionIds };
diff --git a/meshchatx/src/frontend/js/plugins/pluginPermissions.js b/meshchatx/src/frontend/js/plugins/pluginPermissions.js
new file mode 100644
index 00000000..8165a6ea
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/pluginPermissions.js
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @param {string} permissionId
+ * @param {(key: string, values?: Record<string, unknown>) => string} t
+ * @returns {string}
+ */
+export function permissionLabel(permissionId, t) {
+ const key = `plugins.permissions.${String(permissionId).replaceAll(":", ".")}`;
+ const translated = t(key);
+ if (translated && translated !== key) {
+ return translated;
+ }
+ return permissionId;
+}
+
+/**
+ * @param {Record<string, unknown> | null | undefined} manifest
+ * @returns {string[]}
+ */
+export function declaredPermissionIds(manifest) {
+ const permissions = manifest?.permissions || {};
+ /** @type {string[]} */
+ const ids = [];
+ if (Array.isArray(permissions.hooks)) {
+ for (const hook of permissions.hooks) {
+ if (typeof hook === "string" && hook.trim()) {
+ ids.push(`hooks:${hook.trim()}`);
+ }
+ }
+ }
+ if (Array.isArray(permissions.managers)) {
+ for (const manager of permissions.managers) {
+ if (typeof manager === "string" && manager.trim()) {
+ ids.push(`managers:${manager.trim()}`);
+ }
+ }
+ }
+ if (typeof permissions.storage === "string" && permissions.storage && permissions.storage !== "none") {
+ ids.push(`storage:${permissions.storage}`);
+ }
+ const network = permissions.network;
+ if (network && network !== "none") {
+ ids.push("network:fetch");
+ }
+ return [...new Set(ids)];
+}
+
+/**
+ * @param {Record<string, unknown> | null | undefined} manifest
+ * @param {(key: string, values?: Record<string, unknown>) => string} t
+ * @returns {string[]}
+ */
+export function manifestPermissionSummary(manifest, t = (key) => key) {
+ const ids =
+ Array.isArray(manifest?.declared_permissions) && manifest.declared_permissions.length
+ ? manifest.declared_permissions
+ : declaredPermissionIds(manifest);
+ if (!ids.length) {
+ return [t("plugins.permissions.none")];
+ }
+ return ids.map((id) => permissionLabel(id, t));
+}
diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
index 95809fcf..9ba2ddc0 100644
--- a/meshchatx/src/frontend/js/settings/settingsTabs.js
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -47,7 +47,13 @@ export const SETTINGS_TABS = [
id: "maintenance",
labelKey: "settings.tabs.maintenance",
descriptionKey: "settings.tabs.maintenance_desc",
- sections: ["maintenance", "selftest", "plugins"],
+ sections: ["maintenance", "selftest"],
+ },
+ {
+ id: "plugins",
+ labelKey: "settings.tabs.plugins",
+ descriptionKey: "settings.tabs.plugins_desc",
+ sections: ["plugins"],
},
];
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 507a85c8..b91b4910 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Verborgen — tippen zum Anzeigen",
+ "rpc_key_show": "RPC-Schlüssel anzeigen",
+ "rpc_key_hide": "RPC-Schlüssel verbergen",
+ "refresh_community_interfaces": "Von directory.rns.recipes aktualisieren",
+ "refresh_community_interfaces_busy": "Wird aktualisiert…"
},
"common": {
"open": "Öffnen",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugins",
- "description": "MeshChatX-Plugins installieren, aktivieren und Berechtigungen prüfen.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"remove": "Entfernen",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Plugin-Installation fehlgeschlagen: {reason}",
- "installing": "Plugin wird installiert..."
+ "installing": "Plugin wird installiert...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Status",
"reason_label": "Grund",
"checks_completed": "Alle Prüfungen erfolgreich bestanden.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Fehlerdetails anzeigen",
+ "collapse_reason": "Fehlerdetails ausblenden"
},
"maintenance": {
"title": "Wartung & Daten",
@@ -2996,7 +3033,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Plugins",
+ "plugins_desc": "MeshChatX-Plugins installieren und verwalten"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index bb0f8646..1f787fd9 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -170,12 +170,17 @@
"rpc_config_description": "Copy these lines into another program's [reticulum] section for full shared-instance RPC access (management, interface status, path info).",
"rpc_config_unavailable": "RPC key is not available yet. Start Reticulum as a shared instance first.",
"rpc_config_copied": "RPC config copied to clipboard",
+ "rpc_key_hidden": "Hidden — tap to show",
+ "rpc_key_show": "Show RPC key",
+ "rpc_key_hide": "Hide RPC key",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
"copy_failed": "Failed to copy to clipboard",
"requires_restart": "Requires restart after toggling.",
"show_community_interfaces": "Show Community Interfaces",
"community_interfaces_description": "Show community-maintained presets when adding new interfaces.",
+ "refresh_community_interfaces": "Refresh from directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Refreshing…",
"reliability": "Reliability",
"lxmf_settings_eyebrow": "LXMF",
"privacy_eyebrow": "Privacy",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugins",
- "description": "Install, enable, and review permissions for MeshChatX plugins.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Enable",
"disable": "Disable",
"remove": "Remove",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Plugin install failed: {reason}",
- "installing": "Installing plugin..."
+ "installing": "Installing plugin...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -762,6 +797,8 @@
"bots_lifecycle": "Bot Create / Start / Stop / Delete",
"passed": "Passed",
"failed": "Failed",
+ "expand_reason": "Show failure details",
+ "collapse_reason": "Hide failure details",
"status_label": "Status",
"reason_label": "Reason",
"checks_completed": "All checks passed successfully."
@@ -1573,7 +1610,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Plugins",
+ "plugins_desc": "Install and manage MeshChatX plugins"
},
"shortcut_saved": "Shortcut saved",
"shortcut_deleted": "Shortcut deleted",
@@ -3299,5 +3338,8 @@
"action_getting_started_desc": "Open the setup guide",
"action_changelog": "Changelog",
"action_changelog_desc": "Recent changes"
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index fe18064d..f5882434 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Oculta — toca para mostrar",
+ "rpc_key_show": "Mostrar clave RPC",
+ "rpc_key_hide": "Ocultar clave RPC",
+ "refresh_community_interfaces": "Actualizar desde directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Actualizando…"
},
"common": {
"open": "Abierto",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugins",
- "description": "Instalar, habilitar y revisar permisos de plugins de MeshChatX.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Habilitar",
"disable": "Deshabilitar",
"remove": "Eliminar",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Error al instalar el plugin: {reason}",
- "installing": "Instalando plugin..."
+ "installing": "Instalando plugin...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Estado",
"reason_label": "Motivo",
"checks_completed": "Todas las comprobaciones se completaron con éxito.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Mostrar detalles del fallo",
+ "collapse_reason": "Ocultar detalles del fallo"
},
"maintenance": {
"title": "Datos de mantenimiento",
@@ -1619,7 +1656,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Complementos",
+ "plugins_desc": "Instalar y gestionar complementos de MeshChatX"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 9d3ec933..53271268 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Piilotettu — napauta näyttääksesi",
+ "rpc_key_show": "Näytä RPC-avain",
+ "rpc_key_hide": "Piilota RPC-avain",
+ "refresh_community_interfaces": "Päivitä lähteestä directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Päivitetään…"
},
"common": {
"open": "Avaa",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Liitännäiset",
- "description": "Asenna, ota käyttöön ja tarkista MeshChatX-liitännäisten oikeudet.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Ota käyttöön",
"disable": "Poista käytöstä",
"remove": "Poista",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Lisäosan asennus epäonnistui: {reason}",
- "installing": "Asennetaan lisäosaa..."
+ "installing": "Asennetaan lisäosaa...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Tila",
"reason_label": "Syy",
"checks_completed": "Kaikki tarkistukset suoritettiin onnistuneesti.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Näytä virheen tiedot",
+ "collapse_reason": "Piilota virheen tiedot"
},
"maintenance": {
"title": "Ylläpito ja tiedot",
@@ -1573,7 +1610,9 @@
"privacy": "Yksityisyys",
"privacy_desc": "Data, käyttöoikeudet ja turvallisuus",
"maintenance": "Ylläpito",
- "maintenance_desc": "Siivous, vienti ja tuonti"
+ "maintenance_desc": "Siivous, vienti ja tuonti",
+ "plugins": "Laajennukset",
+ "plugins_desc": "Asenna ja hallitse MeshChatX-laajennuksia"
},
"shortcut_saved": "Näppäinoikotie tallennettu",
"shortcut_deleted": "Näppäinoikotie poistettu",
@@ -3299,5 +3338,8 @@
"action_getting_started_desc": "Open the setup guide",
"action_changelog": "Changelog",
"action_changelog_desc": "Recent changes"
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 6586d453..a41da0ad 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Masquée — appuyer pour afficher",
+ "rpc_key_show": "Afficher la clé RPC",
+ "rpc_key_hide": "Masquer la clé RPC",
+ "refresh_community_interfaces": "Actualiser depuis directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Actualisation…"
},
"common": {
"open": "Ouvrir",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugins",
- "description": "Installer, activer et examiner les permissions des plugins MeshChatX.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Activer",
"disable": "Désactiver",
"remove": "Supprimer",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Échec de l'installation du plugin : {reason}",
- "installing": "Installation du plugin..."
+ "installing": "Installation du plugin...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Statut",
"reason_label": "Raison",
"checks_completed": "Tous les contrôles ont réussi.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Afficher les détails de l’échec",
+ "collapse_reason": "Masquer les détails de l’échec"
},
"maintenance": {
"title": "Maintenance et données",
@@ -1619,7 +1656,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Extensions",
+ "plugins_desc": "Installer et gérer les extensions MeshChatX"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 3f3ad21b..19ce657d 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Nascosta — tocca per mostrare",
+ "rpc_key_show": "Mostra chiave RPC",
+ "rpc_key_hide": "Nascondi chiave RPC",
+ "refresh_community_interfaces": "Aggiorna da directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Aggiornamento…"
},
"common": {
"open": "Apri",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugin",
- "description": "Installa, abilita e rivedi i permessi dei plugin MeshChatX.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Abilita",
"disable": "Disabilita",
"remove": "Rimuovi",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Installazione plugin non riuscita: {reason}",
- "installing": "Installazione plugin..."
+ "installing": "Installazione plugin...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Stato",
"reason_label": "Motivo",
"checks_completed": "Tutti i controlli sono stati superati con successo.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Mostra dettagli dell’errore",
+ "collapse_reason": "Nascondi dettagli dell’errore"
},
"maintenance": {
"title": "Manutenzione e Dati",
@@ -1671,7 +1708,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Plugin",
+ "plugins_desc": "Installa e gestisci i plugin di MeshChatX"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 67ab8656..9ff421d8 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Verborgen — tik om te tonen",
+ "rpc_key_show": "RPC-sleutel tonen",
+ "rpc_key_hide": "RPC-sleutel verbergen",
+ "refresh_community_interfaces": "Vernieuwen vanaf directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Bezig met vernieuwen…"
},
"common": {
"open": "Open",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Plugins",
- "description": "Installeer, schakel in en controleer rechten voor MeshChatX-plugins.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Inschakelen",
"disable": "Uitschakelen",
"remove": "Verwijderen",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Plugin-installatie mislukt: {reason}",
- "installing": "Plugin installeren..."
+ "installing": "Plugin installeren...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Status",
"reason_label": "Reden",
"checks_completed": "Alle controles zijn succesvol geslaagd.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Foutdetails tonen",
+ "collapse_reason": "Foutdetails verbergen"
},
"maintenance": {
"title": "Onderhoud & gegevens",
@@ -1619,7 +1656,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Plugins",
+ "plugins_desc": "MeshChatX-plugins installeren en beheren"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 52aae246..91c91383 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "Скрыт — нажмите, чтобы показать",
+ "rpc_key_show": "Показать RPC-ключ",
+ "rpc_key_hide": "Скрыть RPC-ключ",
+ "refresh_community_interfaces": "Обновить из directory.rns.recipes",
+ "refresh_community_interfaces_busy": "Обновление…"
},
"common": {
"open": "Открыть",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "Плагины",
- "description": "Установка, включение и проверка разрешений плагинов MeshChatX.",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "Включить",
"disable": "Отключить",
"remove": "Удалить",
@@ -720,7 +725,37 @@
"badge_frontend": "UI",
"badge_wasm": "WASM",
"install_failed": "Не удалось установить плагин: {reason}",
- "installing": "Установка плагина..."
+ "installing": "Установка плагина...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "Статус",
"reason_label": "Причина",
"checks_completed": "Все проверки выполнены успешно.",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "Показать детали ошибки",
+ "collapse_reason": "Скрыть детали ошибки"
},
"maintenance": {
"title": "Обслуживание и данные",
@@ -2996,7 +3033,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "Плагины",
+ "plugins_desc": "Установка и управление плагинами MeshChatX"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 6543bbd5..c94cbe60 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -451,7 +451,12 @@
"rpc_config_copied": "RPC config copied to clipboard",
"copy_rpc_config": "Copy RPC Config",
"connected_to_shared_instance": "This MeshChatX process is attached to an external shared Reticulum instance. Edit that instance's config to change share/RPC settings.",
- "copy_failed": "Failed to copy to clipboard"
+ "copy_failed": "Failed to copy to clipboard",
+ "rpc_key_hidden": "已隐藏 — 点按显示",
+ "rpc_key_show": "显示 RPC 密钥",
+ "rpc_key_hide": "隐藏 RPC 密钥",
+ "refresh_community_interfaces": "从 directory.rns.recipes 刷新",
+ "refresh_community_interfaces_busy": "正在刷新…"
},
"common": {
"open": "打开",
@@ -699,7 +704,7 @@
"plugins": {
"settings": {
"title": "插件",
- "description": "安装、启用并查看 MeshChatX 插件权限。",
+ "description": "Install, enable, and review permissions for MeshChatX plugins. ZIP installs show a confirmation dialog with requested permissions and any external URLs.",
"enable": "启用",
"disable": "禁用",
"remove": "移除",
@@ -720,7 +725,37 @@
"badge_frontend": "界面",
"badge_wasm": "WASM",
"install_failed": "插件安装失败:{reason}",
- "installing": "正在安装插件..."
+ "installing": "正在安装插件...",
+ "badge_network": "Network",
+ "network_endpoints": "Network endpoints",
+ "previewing": "Reviewing plugin..."
+ },
+ "permissions": {
+ "none": "No elevated permissions",
+ "hooks.announce.received": "Receive mesh announce events",
+ "hooks.rns.link.event": "Receive RNS link packet and close events",
+ "managers.destinationPath.read": "Read the Reticulum path table",
+ "managers.rnsLink.open": "Open RNS links to destinations",
+ "managers.rnsLink.identify": "Identify on RNS links",
+ "managers.rnsLink.request": "Send request/response over RNS links",
+ "managers.rnsLink.send": "Send packets over RNS links",
+ "managers.rnsLink.close": "Close RNS links",
+ "storage.isolated": "Use isolated plugin storage",
+ "network.fetch": "Make outbound internet HTTP requests"
+ },
+ "install_dialog": {
+ "title": "Install plugin",
+ "network_title": "Install plugin with network access",
+ "message": "Review permissions for \"{name}\" ({id}) before installing.",
+ "permissions": "Requested permissions",
+ "permissions_hint": "Uncheck any permission you do not want to grant. Denied capabilities stay unavailable at runtime.",
+ "grant": "Allow",
+ "network_endpoints": "External network endpoints",
+ "network_endpoints_blocked": "Network access is not granted. These URLs will remain blocked.",
+ "network_endpoints_unknown": "This plugin requests network access but did not declare specific endpoints.",
+ "cancel": "Cancel",
+ "confirm": "Install",
+ "close": "Close"
}
},
"selftest": {
@@ -764,7 +799,9 @@
"status_label": "状态",
"reason_label": "原因",
"checks_completed": "所有检查已成功通过。",
- "http_reticulum_instance_good": "HTTP RNS Instance Settings"
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
+ "expand_reason": "显示失败详情",
+ "collapse_reason": "隐藏失败详情"
},
"maintenance": {
"title": "数据维护",
@@ -1619,7 +1656,9 @@
"privacy": "Privacy",
"privacy_desc": "Data, access, and security",
"maintenance": "Maintenance",
- "maintenance_desc": "Cleanup, export, and import"
+ "maintenance_desc": "Cleanup, export, and import",
+ "plugins": "插件",
+ "plugins_desc": "安装和管理 MeshChatX 插件"
},
"failed_update_reticulum_instance": "Failed to update Reticulum instance settings!"
},
@@ -3299,5 +3338,8 @@
"copy_restart_hint": "Copy scheduled. Closing the app — open MeshChatX again to finish.",
"restart_to_apply": "Restart the app to apply your storage choice.",
"failed": "Could not update storage location."
+ },
+ "self_test": {
+ "websocket_rns_link_good": "WebSocket RNS Link API"
}
}
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index c66c0def..35c99fe4 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -328,106 +328,129 @@ window.api = createApiClient({
},
});
-try {
- await fetchCsrfToken(window.api);
-} catch {
- // CSRF token will be retried on the next mutating request if needed.
+import { waitForNetworkReady } from "./js/networkStartupWait.js";
+
+function setBootSplashLine(text) {
+ const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
+ const line = splash?.querySelector("[data-boot-line]");
+ if (line && text) {
+ line.textContent = text;
+ }
+}
+
+function markBootSplashError() {
+ const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
+ if (splash) {
+ splash.setAttribute("data-state", "error");
+ }
}
-router.beforeEach(async (to, from, next) => {
+const networkReady = await waitForNetworkReady({
+ onLine: setBootSplashLine,
+ onErrorState: markBootSplashError,
+});
+if (networkReady) {
try {
- const response = await window.api.get("/api/v1/auth/status");
- const status = response.data;
- GlobalState.authEnabled = !!status.auth_enabled;
- GlobalState.authenticated = !!status.authenticated;
- GlobalState.authSessionResolved = true;
+ await fetchCsrfToken(window.api);
+ } catch {
+ // CSRF token will be retried on the next mutating request if needed.
+ }
- if (!status.auth_enabled) {
- next();
- return;
- }
+ router.beforeEach(async (to, from, next) => {
+ try {
+ const response = await window.api.get("/api/v1/auth/status");
+ const status = response.data;
+ GlobalState.authEnabled = !!status.auth_enabled;
+ GlobalState.authenticated = !!status.authenticated;
+ GlobalState.authSessionResolved = true;
+
+ if (!status.auth_enabled) {
+ next();
+ return;
+ }
+
+ if (status.authenticated) {
+ if (to.name === "auth") {
+ next("/");
+ } else {
+ next();
+ }
+ return;
+ }
- if (status.authenticated) {
if (to.name === "auth") {
- next("/");
+ next();
+ return;
+ }
+
+ next("/auth");
+ } catch (e) {
+ GlobalState.authSessionResolved = true;
+ if (e.response?.status === 401 || e.response?.status === 403) {
+ GlobalState.authenticated = false;
+ next("/auth");
} else {
next();
}
- return;
}
+ });
- if (to.name === "auth") {
- next();
+ function registerMeshchatServiceWorker() {
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
return;
}
-
- next("/auth");
- } catch (e) {
- GlobalState.authSessionResolved = true;
- if (e.response?.status === 401 || e.response?.status === 403) {
- GlobalState.authenticated = false;
- next("/auth");
- } else {
- next();
- }
+ navigator.serviceWorker.register("/service-worker.js").catch((error) => {
+ const errorMessage = error.message || "";
+ const errorName = error.name || "";
+ if (
+ errorName === "SecurityError" ||
+ errorMessage.includes("SSL certificate") ||
+ errorMessage.includes("certificate")
+ ) {
+ return;
+ }
+ console.debug("Service worker registration failed:", error);
+ });
}
-});
-function registerMeshchatServiceWorker() {
- if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
- return;
- }
- navigator.serviceWorker.register("/service-worker.js").catch((error) => {
- const errorMessage = error.message || "";
- const errorName = error.name || "";
- if (
- errorName === "SecurityError" ||
- errorMessage.includes("SSL certificate") ||
- errorMessage.includes("certificate")
- ) {
+ function bootstrap() {
+ registerMeshchatServiceWorker();
+ const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
+ try {
+ createApp(App).use(router).use(vuetify).use(i18n).use(vClickOutside).mount("#app");
+ } catch (e) {
+ console.error("MeshChatX bootstrap failed:", e);
+ if (splash) {
+ splash.setAttribute("data-state", "error");
+ const line = splash.querySelector("[data-boot-line]");
+ if (line) {
+ line.textContent = "Failed to start. Try closing and reopening the app.";
+ }
+ }
return;
}
- console.debug("Service worker registration failed:", error);
- });
-}
-
-function bootstrap() {
- registerMeshchatServiceWorker();
- const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null;
- try {
- createApp(App).use(router).use(vuetify).use(i18n).use(vClickOutside).mount("#app");
- } catch (e) {
- console.error("MeshChatX bootstrap failed:", e);
if (splash) {
- splash.setAttribute("data-state", "error");
- const line = splash.querySelector("[data-boot-line]");
- if (line) {
- line.textContent = "Failed to start. Try closing and reopening the app.";
- }
+ splash.remove();
}
- return;
- }
- if (splash) {
- splash.remove();
+ void startCodec2ScriptsBackgroundLoad();
+ void loadPluginsIfEnabled();
}
- void startCodec2ScriptsBackgroundLoad();
- void loadPluginsIfEnabled();
-}
-async function loadPluginsIfEnabled() {
- if (!(GlobalState.authenticated || !GlobalState.authEnabled)) {
- return;
- }
- try {
- const response = await window.api.get("/api/v1/plugins");
- GlobalState.pluginsEnabled = response.data?.plugins_enabled !== false;
- if (!GlobalState.pluginsEnabled) {
+ async function loadPluginsIfEnabled() {
+ if (!(GlobalState.authenticated || !GlobalState.authEnabled)) {
return;
}
- await pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value);
- } catch (error) {
- console.debug("Plugin host bootstrap failed:", error);
+ try {
+ const response = await window.api.get("/api/v1/plugins");
+ GlobalState.pluginsEnabled = response.data?.plugins_enabled !== false;
+ if (!GlobalState.pluginsEnabled) {
+ return;
+ }
+ await pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value);
+ } catch (error) {
+ console.debug("Plugin host bootstrap failed:", error);
+ }
}
-}
-bootstrap();
+ bootstrap();
+}
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
index 6267173d..33e6e2b9 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
@@ -132,6 +132,9 @@ Practical extension paths today:
- Frontend pages wired through registries
- New settings via `ConfigManager` and CLI or environment variables
- Database schema changes through migrations
+- Generic RNS Link transport over WebSocket (`rns.link.*`) for external consoles and plugins (see **RNS Link API**)
+
+Granted plugin manager capabilities include `destinationPath.read` and `rnsLink.open` / `identify` / `request` / `send` / `close`. Hooks include `announce.received` and `rns.link.event`. Storage (`storage:isolated`) and outbound HTTP (`network:fetch`) are also grantable; install preview scans plugin files for external URLs and stores the user-selected grant subset.
When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md b/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md
new file mode 100644
index 00000000..f9e6e3ee
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/rns-link-api.md
@@ -0,0 +1,70 @@
+# Generic RNS Link API
+
+MeshChatX exposes a generic Reticulum Link transport over the main WebSocket (`/ws`) so external apps and plugins can open links, run request/response exchanges, send packets, and tear links down without going through NomadNet-specific helpers.
+
+This is the surface used by microReticulum management consoles that treat MeshChatX as an RNS transport.
+
+## Auth
+
+When password auth is enabled, all `rns.link.*` client messages require an authenticated session (same rule as other WebSocket mutators).
+
+## Client → server
+
+| `type` | Fields | Behavior |
+| ------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `rns.link.open` | `destination_hash` (hex), `aspect` (dot-separated), `request_id`, `auto_identify?` | Open or reuse a cached link to `(aspect, destination_hash)`. Streams `phase` then `success` / `failure`. |
+| `rns.link.identify` | `destination_hash`, `aspect`, `request_id` | Call `link.identify(local_identity)` on the cached link. |
+| `rns.link.request` | `destination_hash`, `aspect`, `path`, `request_id`, `data_b64?`, `timeout?` | Ensure the link is open, then `link.request(path, data=…)`. `data_b64` / reply `body_b64` are msgpack payloads, base64-encoded. |
+| `rns.link.send` | `destination_hash`, `aspect`, `payload_b64`, `request_id` | Send a raw packet on the cached link. |
+| `rns.link.close` | `destination_hash`, `aspect`, `request_id` | Teardown and uncache the link. |
+
+`aspect` is split on `.` into RNS app name + sub-aspects (for example `microrn.mgmt`).
+
+Long-running `open` / `request` work is tracked per WebSocket client and cancelled when that client disconnects.
+
+## Server → client
+
+Per-`request_id` replies reuse the same `type` with `status` of `phase`, `progress`, `success`, or `failure`.
+
+Broadcast events:
+
+| `type` | `event` | Notes |
+| ---------------- | ----------------- | ---------------------- |
+| `rns.link.event` | `packet_received` | Includes `payload_b64` |
+| `rns.link.event` | `link_closed` | Cached link removed |
+
+## Plugin capabilities
+
+Plugins that declare the matching `permissions.managers` entries can call the same transport through `POST /api/v1/plugins/{id}/invoke` with `method: "callManager"`:
+
+- `rnsLink.open`
+- `rnsLink.identify`
+- `rnsLink.request`
+- `rnsLink.send`
+- `rnsLink.close`
+
+Subscribe to async link traffic with `permissions.hooks: ["rns.link.event"]`. Events arrive as `plugin.event` WebSocket frames with `event: "rns.link.event"`.
+
+Example manifest fragment:
+
+```json
+{
+ "permissions": {
+ "hooks": ["rns.link.event"],
+ "managers": ["rnsLink.open", "rnsLink.identify", "rnsLink.request", "rnsLink.send", "rnsLink.close"],
+ "storage": "isolated",
+ "network": "none"
+ }
+}
+```
+
+## Implementation
+
+- `meshchatx/src/backend/rns_link_manager.py` — link cache, open/identify/request/send/close
+- `meshchatx/meshchat.py` — WebSocket dispatch and per-client task tracking
+- `meshchatx/src/backend/plugin_manager.py` — capability wrappers and hook fan-out
+
+## Related
+
+- **Plugins** in Tools docs for install/enable flow
+- **Architecture** for the plugin runtime overview
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
index bc98c672..f87c6d99 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
@@ -79,6 +79,12 @@ When `rrc_enabled` is on, you can run a local RRC hub from relay chat server set
Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+Plugins are capability-gated, not fully open-ended: they cannot rewrite core MeshChatX. Supported runtimes are **frontend JS** (Worker) and optional **backend WASM**. Python plugins are not supported.
+
+ZIP install shows a confirmation dialog that lists requested permissions (hooks, managers, storage, `network:fetch`) and any scanned/declared external HTTP URLs. You can deny individual grants before install; denied capabilities stay blocked at runtime. Misbehaving plugins auto-disable after an error budget.
+
+Plugins that need a generic Reticulum Link transport (for example a microReticulum node management UI) can request `rnsLink.*` manager capabilities and the `rns.link.event` hook. External web apps can use the same transport over `/ws` without installing a plugin. See **RNS Link API**.
+
Disable plugins at startup with `--disable-plugins` if you need a minimal surface.
## Command palette
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
index feb5faf0..c53548c3 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
+++ b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
@@ -1,103 +1,151 @@
{
"version": 1,
"default_language": "en",
- "languages": [{ "code": "en", "name": "English" }],
+ "languages": [
+ {
+ "code": "en",
+ "name": "English"
+ }
+ ],
"sections": [
{
"id": "overview",
"order": 1,
- "title": { "en": "Overview" },
+ "title": {
+ "en": "Overview"
+ },
"items": [
{
"path": "en/getting-started.md",
"lang": "en",
- "title": { "en": "Getting started" }
+ "title": {
+ "en": "Getting started"
+ }
},
{
"path": "en/installation.md",
"lang": "en",
- "title": { "en": "Installation and setup" }
+ "title": {
+ "en": "Installation and setup"
+ }
},
{
"path": "en/architecture.md",
"lang": "en",
- "title": { "en": "Architecture and design" }
+ "title": {
+ "en": "Architecture and design"
+ }
}
]
},
{
"id": "features",
"order": 2,
- "title": { "en": "Features" },
+ "title": {
+ "en": "Features"
+ },
"items": [
{
"path": "en/messaging.md",
"lang": "en",
- "title": { "en": "LXMF messaging" }
+ "title": {
+ "en": "LXMF messaging"
+ }
},
{
"path": "en/audio-calls.md",
"lang": "en",
- "title": { "en": "Audio calls (LXST)" }
+ "title": {
+ "en": "Audio calls (LXST)"
+ }
},
{
"path": "en/nomad-network.md",
"lang": "en",
- "title": { "en": "Nomad Network and Mesh Server" }
+ "title": {
+ "en": "Nomad Network and Mesh Server"
+ }
},
{
"path": "en/interfaces.md",
"lang": "en",
- "title": { "en": "Reticulum interfaces" }
+ "title": {
+ "en": "Reticulum interfaces"
+ }
},
{
"path": "en/tools.md",
"lang": "en",
- "title": { "en": "Tools and utilities" }
+ "title": {
+ "en": "Tools and utilities"
+ }
+ },
+ {
+ "path": "en/rns-link-api.md",
+ "lang": "en",
+ "title": {
+ "en": "RNS Link API"
+ }
},
{
"path": "en/identity-and-security.md",
"lang": "en",
- "title": { "en": "Identities, privacy, and security" }
+ "title": {
+ "en": "Identities, privacy, and security"
+ }
}
]
},
{
"id": "authoring",
"order": 3,
- "title": { "en": "Authoring" },
+ "title": {
+ "en": "Authoring"
+ },
"items": [
{
"path": "en/nomadmesh-pages.md",
"lang": "en",
- "title": { "en": "NomadNet page formats" }
+ "title": {
+ "en": "NomadNet page formats"
+ }
}
]
},
{
"id": "platforms",
"order": 4,
- "title": { "en": "Platform guides" },
+ "title": {
+ "en": "Platform guides"
+ },
"items": [
{
"path": "en/platform-guides/raspberry-pi.md",
"lang": "en",
- "title": { "en": "Raspberry Pi" }
+ "title": {
+ "en": "Raspberry Pi"
+ }
},
{
"path": "en/platform-guides/android-termux.md",
"lang": "en",
- "title": { "en": "Android (Termux)" }
+ "title": {
+ "en": "Android (Termux)"
+ }
},
{
"path": "en/platform-guides/quest-sidequest.md",
"lang": "en",
- "title": { "en": "Meta Quest (SideQuest)" }
+ "title": {
+ "en": "Meta Quest (SideQuest)"
+ }
},
{
"path": "en/platform-guides/linux-sandbox.md",
"lang": "en",
- "title": { "en": "Linux sandboxing" }
+ "title": {
+ "en": "Linux sandboxing"
+ }
}
]
}
diff --git a/scripts/e2e/start-e2e-stack.sh b/scripts/e2e/start-e2e-stack.sh
index 2301fad3..ad3d8f72 100755
--- a/scripts/e2e/start-e2e-stack.sh
+++ b/scripts/e2e/start-e2e-stack.sh
@@ -39,17 +39,19 @@ uv run python -m meshchatx.meshchat \
&
BACK_PID=$!
-echo "E2E: waiting for /api/v1/status (HTTP 200)..."
+echo "E2E: waiting for /api/v1/status network_ready..."
ready=0
for i in $(seq 1 240); do
if ! kill -0 "$BACK_PID" 2>/dev/null; then
echo "E2E: backend process exited before becoming ready"
exit 1
fi
- if curl -sf "http://127.0.0.1:${BACKEND_PORT}/api/v1/status" >/dev/null 2>&1; then
- ready=1
- echo "E2E: backend ready after ${i}s"
- break
+ if body="$(curl -sf "http://127.0.0.1:${BACKEND_PORT}/api/v1/status" 2>/dev/null)"; then
+ if printf '%s' "$body" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("status")=="ok" or d.get("network_ready") else 1)'; then
+ ready=1
+ echo "E2E: backend ready after ${i}s"
+ break
+ fi
fi
sleep 1
done
diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index 700492b1..fecd940b 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -179,9 +179,15 @@ _SERVER_BIND_STATUS_SCHEMA: dict = {
API_V1_STATUS_SCHEMA: dict = {
"type": "object",
- "required": ["status"],
+ "required": ["status", "stage", "network_ready"],
"properties": {
- "status": {"type": "string", "const": "ok"},
+ "status": {"type": "string", "enum": ["ok", "starting", "failed"]},
+ "stage": {
+ "type": "string",
+ "enum": ["http", "starting", "rns", "identity", "ready", "failed"],
+ },
+ "network_ready": {"type": "boolean"},
+ "error": {"type": "string"},
**_SERVER_BIND_STATUS_SCHEMA,
},
"additionalProperties": False,
@@ -231,6 +237,7 @@ SELF_TEST_SCHEMA: dict = {
"http_favourites_good",
"http_telephone_good",
"websocket_good",
+ "websocket_rns_link_good",
"bots_lifecycle",
],
"properties": {
@@ -265,6 +272,7 @@ SELF_TEST_SCHEMA: dict = {
"http_favourites_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_telephone_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"websocket_good": SELF_TEST_STATUS_ITEM_SCHEMA,
+ "websocket_rns_link_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"bots_lifecycle": SELF_TEST_STATUS_ITEM_SCHEMA,
},
"additionalProperties": False,
@@ -284,6 +292,12 @@ AUTH_STATUS_SCHEMA: dict = {
"auth_enabled": {"type": "boolean"},
"password_set": {"type": "boolean"},
"authenticated": {"type": "boolean"},
+ "network_ready": {"type": "boolean"},
+ "status": {"type": "string", "enum": ["starting", "ok", "failed"]},
+ "stage": {
+ "type": "string",
+ "enum": ["http", "starting", "rns", "identity", "ready", "failed"],
+ },
"error": {"type": "string"},
},
"additionalProperties": False,
diff --git a/tests/backend/benchmarking_utils.py b/tests/backend/benchmarking_utils.py
index e9114ec1..3a9aea88 100644
--- a/tests/backend/benchmarking_utils.py
+++ b/tests/backend/benchmarking_utils.py
@@ -2,6 +2,7 @@
import gc
import os
+import statistics
import time
from functools import wraps
@@ -14,75 +15,234 @@ def get_memory_usage_mb():
return process.memory_info().rss / (1024 * 1024)
+def median(values):
+ """Return the median of a non-empty sequence of numbers."""
+ return statistics.median(values)
+
+
+def median_abs_deviation(values, center=None):
+ """Median absolute deviation (MAD) of ``values`` around ``center``."""
+ if not values:
+ return 0.0
+ if center is None:
+ center = median(values)
+ return median([abs(v - center) for v in values])
+
+
+def coefficient_of_variation(values):
+ """Sample CV (stdev / mean). Returns 0 when mean is near zero."""
+ if len(values) < 2:
+ return 0.0
+ mean = statistics.mean(values)
+ if abs(mean) < 1e-12:
+ return 0.0
+ return statistics.stdev(values) / abs(mean)
+
+
class BenchmarkResult:
- def __init__(self, name, duration_ms, memory_delta_mb):
+ def __init__(
+ self,
+ name,
+ duration_ms,
+ memory_delta_mb,
+ samples_ms=None,
+ iterations=1,
+ ):
self.name = name
self.duration_ms = duration_ms
self.memory_delta_mb = memory_delta_mb
+ self.samples_ms = list(samples_ms) if samples_ms else [duration_ms]
+ self.iterations = iterations
+ self.mad_ms = median_abs_deviation(self.samples_ms, center=duration_ms)
+ self.cv = coefficient_of_variation(self.samples_ms)
def __repr__(self):
- return f"<BenchmarkResult {self.name}: {self.duration_ms:.2f}ms, {self.memory_delta_mb:.2f}MB>"
+ return (
+ f"<BenchmarkResult {self.name}: {self.duration_ms:.2f}ms "
+ f"(mad={self.mad_ms:.2f}, cv={self.cv:.2f}), "
+ f"{self.memory_delta_mb:.2f}MB>"
+ )
+
+ def merge_runs(self, others):
+ """Return a new result whose duration is the median across run medians."""
+ run_medians = [self.duration_ms] + [o.duration_ms for o in others]
+ run_mem = [self.memory_delta_mb] + [o.memory_delta_mb for o in others]
+ all_samples = list(self.samples_ms)
+ for o in others:
+ all_samples.extend(o.samples_ms)
+ return BenchmarkResult(
+ self.name,
+ median(run_medians),
+ median(run_mem),
+ samples_ms=all_samples,
+ iterations=self.iterations,
+ )
+
+def benchmark(name=None, iterations=1, warmup=1):
+ """Decorator to benchmark a function's execution time and memory delta.
-def benchmark(name=None, iterations=1):
- """Decorator to benchmark a function's execution time and memory delta."""
+ Each iteration is timed separately with ``perf_counter``. The reported
+ duration is the median of per-iteration samples (more stable than mean
+ under CI noise). A short warmup pass is discarded.
+ """
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
bench_name = name or func.__name__
- # Warm up and GC
gc.collect()
- time.sleep(0.1)
+ time.sleep(0.05)
- start_mem = get_memory_usage_mb()
- start_time = time.time()
+ for _ in range(max(0, warmup)):
+ func(*args, **kwargs)
+ gc.collect()
+ start_mem = get_memory_usage_mb()
+ samples_ms = []
result_val = None
for _ in range(iterations):
+ t0 = time.perf_counter()
result_val = func(*args, **kwargs)
+ t1 = time.perf_counter()
+ samples_ms.append((t1 - t0) * 1000.0)
- end_time = time.time()
- # Force GC to see persistent memory growth
gc.collect()
end_mem = get_memory_usage_mb()
- duration = (end_time - start_time) * 1000 / iterations
+ duration = median(samples_ms)
mem_delta = end_mem - start_mem
+ mad = median_abs_deviation(samples_ms, center=duration)
+ cv = coefficient_of_variation(samples_ms)
print(f"BENCHMARK: {bench_name}")
- print(f" Iterations: {iterations}")
- print(f" Avg Duration: {duration:.2f} ms")
+ print(f" Iterations: {iterations} (warmup={warmup})")
+ print(f" Median Duration: {duration:.3f} ms")
+ print(f" MAD: {mad:.3f} ms CV: {cv:.3f}")
+ if len(samples_ms) >= 2:
+ print(
+ f" Range: {min(samples_ms):.3f} .. {max(samples_ms):.3f} ms",
+ )
print(f" Memory Delta: {mem_delta:.2f} MB")
- return result_val, BenchmarkResult(bench_name, duration, mem_delta)
+ return result_val, BenchmarkResult(
+ bench_name,
+ duration,
+ mem_delta,
+ samples_ms=samples_ms,
+ iterations=iterations,
+ )
return wrapper
return decorator
-class MemoryTracker:
- """Helper to track memory changes over a block of code."""
+def aggregate_run_results(runs):
+ """Aggregate a list of result-lists (one per suite run) by benchmark name.
+
+ Returns a list of ``BenchmarkResult`` with median-of-run-medians duration.
+ """
+ if not runs:
+ return []
+ by_name = {}
+ order = []
+ for run_results in runs:
+ for result in run_results:
+ if result.name not in by_name:
+ by_name[result.name] = []
+ order.append(result.name)
+ by_name[result.name].append(result)
+
+ aggregated = []
+ for name in order:
+ group = by_name[name]
+ first, rest = group[0], group[1:]
+ aggregated.append(first.merge_runs(rest) if rest else first)
+ return aggregated
+
+
+def adaptive_alert_ratio(baseline_ms):
+ """Looser ratio thresholds for tiny baselines (CI scheduler noise)."""
+ if baseline_ms < 1.0:
+ return 4.0
+ if baseline_ms < 5.0:
+ return 2.5
+ if baseline_ms < 20.0:
+ return 2.0
+ return 1.5
+
+
+def should_alert_regression(
+ current_ms,
+ previous_ms,
+ *,
+ noise_floor_ms=0.5,
+ min_abs_delta_ms=1.5,
+ fail_ratio=None,
+ current_cv=None,
+ previous_cv=None,
+ max_cv_for_strict=0.35,
+):
+ """Decide whether a slower current value is a real regression.
+
+ Returns ``(alert: bool, reason: str)``. Skips alerts when both values sit
+ under the noise floor, when the absolute delta is tiny, or when the ratio
+ is within an adaptive threshold. High CV on either side widens the bar.
+ """
+ if previous_ms <= 0:
+ return False, "no previous baseline"
+
+ abs_delta = current_ms - previous_ms
+ if abs_delta <= 0:
+ return False, "improved or unchanged"
+
+ if current_ms < noise_floor_ms and previous_ms < noise_floor_ms:
+ return False, f"both under noise floor ({noise_floor_ms} ms)"
+
+ if abs_delta < min_abs_delta_ms:
+ return (
+ False,
+ f"abs delta {abs_delta:.3f} ms < min {min_abs_delta_ms} ms",
+ )
- def __init__(self, name):
- self.name = name
- self.start_mem = 0
- self.end_mem = 0
-
- def __enter__(self):
- gc.collect()
- self.start_mem = get_memory_usage_mb()
- self.start_time = time.time()
- return self
-
- def __exit__(self, exc_type, exc_val, exc_tb):
- self.end_time = time.time()
- gc.collect()
- self.end_mem = get_memory_usage_mb()
- self.duration_ms = (self.end_time - self.start_time) * 1000
- self.mem_delta = self.end_mem - self.start_mem
- print(
- f"TRACKER [{self.name}]: {self.duration_ms:.2f}ms, {self.mem_delta:.2f}MB",
+ ratio = current_ms / previous_ms
+ threshold = adaptive_alert_ratio(previous_ms)
+ if fail_ratio is not None:
+ threshold = fail_ratio
+
+ noisy = False
+ if current_cv is not None and current_cv > max_cv_for_strict:
+ noisy = True
+ if previous_cv is not None and previous_cv > max_cv_for_strict:
+ noisy = True
+ if noisy:
+ threshold = max(threshold, threshold * 1.25)
+
+ if ratio < threshold:
+ return (
+ False,
+ f"ratio {ratio:.2f}x within adaptive threshold {threshold:.2f}x",
)
+
+ return (
+ True,
+ f"{ratio:.2f}x slower (threshold {threshold:.2f}x), +{abs_delta:.3f} ms",
+ )
+
+
+def parse_extra_stats(extra):
+ """Parse ``mad=`` / ``cv=`` / ``runs=`` fields from github-action-benchmark extra."""
+ out = {}
+ if not extra:
+ return out
+ for part in str(extra).replace(",", " ").split():
+ if "=" not in part:
+ continue
+ key, _, raw = part.partition("=")
+ try:
+ out[key.strip().lower()] = float(raw)
+ except ValueError:
+ continue
+ return out
diff --git a/tests/backend/compare_benchmarks.py b/tests/backend/compare_benchmarks.py
new file mode 100644
index 00000000..c49722b6
--- /dev/null
+++ b/tests/backend/compare_benchmarks.py
@@ -0,0 +1,306 @@
+# SPDX-License-Identifier: 0BSD
+"""Smart benchmark regression gate for CI.
+
+github-action-benchmark only supports a single ratio threshold. Sub-millisecond
+SQLite ops on shared runners routinely swing 2-3x from scheduler noise, so a
+flat ratio alert is useless. This script:
+
+1. Loads current suite JSON (customSmallerIsBetter) and the cached baseline.
+2. Applies noise-floor, absolute-delta, and adaptive-ratio heuristics.
+3. Writes a human-readable summary and exits non-zero only on real regressions.
+4. Optionally updates the baseline cache when the run is clean (or always when
+ ``--update-baseline`` is set), so the next push compares against a stable
+ median rather than a single noisy sample.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from datetime import datetime, timezone
+
+_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+sys.path.insert(0, _REPO_ROOT)
+
+from tests.backend.benchmarking_utils import ( # noqa: E402
+ parse_extra_stats,
+ should_alert_regression,
+)
+
+
+def _load_entries(path):
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+ if isinstance(data, list):
+ return data
+ if isinstance(data, dict):
+ # github-action-benchmark external-data shape
+ if "entries" in data and isinstance(data["entries"], dict):
+ # Prefer the first suite key (usually the tool name)
+ for _suite, entries in data["entries"].items():
+ if isinstance(entries, list) and entries:
+ # Prefer the newest commit's last values: entries are
+ # historical lists of {commit, date, tool, benches}
+ latest = entries[-1]
+ benches = latest.get("benches") or latest.get("benchmarks")
+ if isinstance(benches, list):
+ return benches
+ if isinstance(latest, list):
+ return latest
+ if "benches" in data and isinstance(data["benches"], list):
+ return data["benches"]
+ raise ValueError(f"Unrecognized benchmark JSON shape in {path}")
+
+
+def _index_by_name(entries):
+ out = {}
+ for entry in entries:
+ name = entry.get("name")
+ if not name:
+ continue
+ out[name] = entry
+ return out
+
+
+def _value_ms(entry):
+ return float(entry["value"])
+
+
+def compare(
+ current_path,
+ previous_path,
+ *,
+ noise_floor_ms=0.5,
+ min_abs_delta_ms=1.5,
+ summary_path=None,
+):
+ current = _index_by_name(_load_entries(current_path))
+ previous = {}
+ if previous_path and os.path.isfile(previous_path):
+ try:
+ previous = _index_by_name(_load_entries(previous_path))
+ except (OSError, ValueError, json.JSONDecodeError, KeyError, TypeError) as exc:
+ print(
+ f"WARNING: could not load previous baseline ({exc}); treating as first run"
+ )
+ previous = {}
+
+ rows = []
+ alerts = []
+ improvements = []
+ skipped = []
+
+ for name in sorted(current):
+ cur = current[name]
+ cur_ms = _value_ms(cur)
+ cur_stats = parse_extra_stats(cur.get("extra", ""))
+ prev = previous.get(name)
+ if prev is None:
+ rows.append(
+ {
+ "name": name,
+ "current": cur_ms,
+ "previous": None,
+ "ratio": None,
+ "status": "new",
+ "detail": "no previous baseline",
+ }
+ )
+ continue
+
+ prev_ms = _value_ms(prev)
+ prev_stats = parse_extra_stats(prev.get("extra", ""))
+ ratio = cur_ms / prev_ms if prev_ms > 0 else None
+ alert, detail = should_alert_regression(
+ cur_ms,
+ prev_ms,
+ noise_floor_ms=noise_floor_ms,
+ min_abs_delta_ms=min_abs_delta_ms,
+ current_cv=cur_stats.get("cv"),
+ previous_cv=prev_stats.get("cv"),
+ )
+ if alert:
+ status = "REGRESSION"
+ alerts.append(name)
+ elif (
+ ratio is not None
+ and ratio < 0.85
+ and (prev_ms - cur_ms) >= min_abs_delta_ms
+ ):
+ status = "improved"
+ improvements.append(name)
+ elif (
+ "noise floor" in detail
+ or "abs delta" in detail
+ or "within adaptive" in detail
+ ):
+ status = "noise"
+ skipped.append(name)
+ else:
+ status = "ok"
+
+ rows.append(
+ {
+ "name": name,
+ "current": cur_ms,
+ "previous": prev_ms,
+ "ratio": ratio,
+ "status": status,
+ "detail": detail,
+ }
+ )
+
+ missing = sorted(set(previous) - set(current))
+ for name in missing:
+ rows.append(
+ {
+ "name": name,
+ "current": None,
+ "previous": _value_ms(previous[name]),
+ "ratio": None,
+ "status": "removed",
+ "detail": "present in baseline only",
+ }
+ )
+
+ lines = [
+ "MeshChatX Backend Benchmark Gate",
+ f"Current: {current_path}",
+ f"Previous: {previous_path or '(none)'}",
+ f"Noise floor: {noise_floor_ms} ms | Min abs delta: {min_abs_delta_ms} ms",
+ "",
+ f"{'Benchmark':42} {'Curr':>10} {'Prev':>10} {'Ratio':>8} Status",
+ "-" * 90,
+ ]
+ for row in rows:
+ cur_s = f"{row['current']:.3f}" if row["current"] is not None else "-"
+ prev_s = f"{row['previous']:.3f}" if row["previous"] is not None else "-"
+ ratio_s = f"{row['ratio']:.2f}x" if row["ratio"] is not None else "-"
+ lines.append(
+ f"{row['name'][:42]:42} {cur_s:>10} {prev_s:>10} {ratio_s:>8} "
+ f"{row['status']}"
+ )
+ if row["status"] == "REGRESSION":
+ lines.append(f" -> {row['detail']}")
+
+ lines.append("-" * 90)
+ lines.append(
+ f"Regressions: {len(alerts)} | Improvements: {len(improvements)} | "
+ f"Noise-skipped: {len(skipped)} | New: "
+ f"{sum(1 for r in rows if r['status'] == 'new')}"
+ )
+ if alerts:
+ lines.append("ALERT: " + ", ".join(alerts))
+ else:
+ lines.append("No actionable regressions.")
+
+ text = "\n".join(lines) + "\n"
+ print(text, end="")
+ if summary_path:
+ os.makedirs(os.path.dirname(summary_path) or ".", exist_ok=True)
+ with open(summary_path, "w", encoding="utf-8") as f:
+ f.write(text)
+ # Also append to GitHub job summary when available
+ gh_summary = os.environ.get("GITHUB_STEP_SUMMARY")
+ if gh_summary:
+ with open(gh_summary, "a", encoding="utf-8") as f:
+ f.write("## Benchmark gate\n\n```\n")
+ f.write(text)
+ f.write("```\n")
+
+ return 1 if alerts else 0, rows
+
+
+def update_baseline(
+ current_path, baseline_path, suite_name="MeshChatX Backend Benchmarks"
+):
+ """Write/merge current results into github-action-benchmark external-data JSON."""
+ current_entries = _load_entries(current_path)
+ os.makedirs(os.path.dirname(baseline_path) or ".", exist_ok=True)
+
+ commit = os.environ.get("GITHUB_SHA", "local")
+ date_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
+ record = {
+ "commit": {
+ "id": commit,
+ "message": os.environ.get("BENCHMARK_COMMIT_MESSAGE", "benchmark run"),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "url": "",
+ },
+ "date": date_ms,
+ "tool": "customSmallerIsBetter",
+ "benches": current_entries,
+ }
+
+ data = {"lastUpdate": date_ms, "repoUrl": "", "entries": {suite_name: []}}
+ if os.path.isfile(baseline_path):
+ try:
+ with open(baseline_path, encoding="utf-8") as f:
+ existing = json.load(f)
+ if isinstance(existing, dict) and "entries" in existing:
+ data = existing
+ except (OSError, json.JSONDecodeError):
+ pass
+
+ entries = data.setdefault("entries", {}).setdefault(suite_name, [])
+ # Keep a short history so cache stays small; compare uses the latest.
+ entries.append(record)
+ data["entries"][suite_name] = entries[-20:]
+ data["lastUpdate"] = date_ms
+
+ with open(baseline_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ print(f"Baseline updated: {baseline_path} ({len(current_entries)} metrics)")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description="Smart CI benchmark regression gate")
+ parser.add_argument("--current", required=True, help="Current bench JSON path")
+ parser.add_argument(
+ "--previous",
+ default=None,
+ help="Previous baseline JSON (github-action-benchmark external-data or flat list)",
+ )
+ parser.add_argument(
+ "--baseline-out",
+ default=None,
+ help="If set, write/update baseline cache at this path after a clean run",
+ )
+ parser.add_argument(
+ "--update-baseline",
+ action="store_true",
+ help="Always update baseline even when regressions are found",
+ )
+ parser.add_argument("--noise-floor-ms", type=float, default=0.5)
+ parser.add_argument("--min-abs-delta-ms", type=float, default=1.5)
+ parser.add_argument("--summary", default=None, help="Write text summary to PATH")
+ parser.add_argument(
+ "--suite-name",
+ default="MeshChatX Backend Benchmarks",
+ help="Suite key inside external-data JSON",
+ )
+ args = parser.parse_args(argv)
+
+ code, _rows = compare(
+ args.current,
+ args.previous,
+ noise_floor_ms=args.noise_floor_ms,
+ min_abs_delta_ms=args.min_abs_delta_ms,
+ summary_path=args.summary,
+ )
+
+ should_update = args.baseline_out and (args.update_baseline or code == 0)
+ if should_update:
+ update_baseline(args.current, args.baseline_out, suite_name=args.suite_name)
+ elif args.baseline_out and code != 0:
+ print(
+ "Baseline not updated (regressions present). Re-run with --update-baseline to force."
+ )
+
+ return code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 5089154c..253aed18 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -736,6 +736,10 @@
"method": "POST",
"path": "/api/v1/plugins/install"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/preview"
+ },
{
"method": "DELETE",
"path": "/api/v1/plugins/{plugin_id}"
diff --git a/tests/backend/fixtures/ws_message_manifest.json b/tests/backend/fixtures/ws_message_manifest.json
index 4091663d..7861b925 100644
--- a/tests/backend/fixtures/ws_message_manifest.json
+++ b/tests/backend/fixtures/ws_message_manifest.json
@@ -17,9 +17,15 @@
"nomadnet.page.archive.load",
"nomadnet.page.archives.get",
"nomadnet.page.download",
- "ping"
+ "ping",
+ "rns.link.close",
+ "rns.link.identify",
+ "rns.link.open",
+ "rns.link.request",
+ "rns.link.send"
],
"client_direct_responses": [
+ "error",
"keyboard_shortcuts",
"lxm.generate_paper_uri.result",
"lxm.ingest_uri.result",
@@ -29,7 +35,12 @@
"nomadnet.page.archive.added",
"nomadnet.page.archives",
"nomadnet.page.download",
- "pong"
+ "pong",
+ "rns.link.close",
+ "rns.link.identify",
+ "rns.link.open",
+ "rns.link.request",
+ "rns.link.send"
],
"server_broadcast": [
"announce",
@@ -47,11 +58,13 @@
"rncp.fetch.completed",
"rncp.send.completed",
"rncp.transfer.progress",
+ "rns.link.event",
"rnsh.output",
"rnsh.session.change",
"rrc.change",
"rrc.message",
"rrc.server.change",
+ "startup_status",
"telephone_call_ended",
"telephone_call_established",
"telephone_initiation_status",
diff --git a/tests/backend/run_comprehensive_benchmarks.py b/tests/backend/run_comprehensive_benchmarks.py
index 3dce52e4..9cb873b4 100644
--- a/tests/backend/run_comprehensive_benchmarks.py
+++ b/tests/backend/run_comprehensive_benchmarks.py
@@ -23,8 +23,12 @@ from meshchatx.src.backend.database.telephone import TelephoneDAO # noqa: E402
from meshchatx.src.backend.database.voicemails import VoicemailDAO # noqa: E402
from meshchatx.src.backend.identity_manager import IdentityManager # noqa: E402
from tests.backend.benchmarking_utils import ( # noqa: E402
+ BenchmarkResult,
+ aggregate_run_results,
benchmark,
get_memory_usage_mb,
+ median,
+ median_abs_deviation,
)
@@ -41,11 +45,39 @@ class BackendBenchmarker:
self.db.close()
shutil.rmtree(self.temp_dir)
- def run_all(self, extreme=False, json_output_path=None):
+ def reset_db(self):
+ """Fresh database between suite runs so state does not accumulate."""
+ self.db.close()
+ for name in os.listdir(self.temp_dir):
+ path = os.path.join(self.temp_dir, name)
+ if os.path.isfile(path):
+ os.remove(path)
+ self.db_path = os.path.join(self.temp_dir, "benchmark.db")
+ self.db = Database(self.db_path)
+ self.db.initialize()
+ self.results = []
+ self.my_hash = secrets.token_hex(16)
+
+ def run_all(self, extreme=False, json_output_path=None, runs=1):
+ runs = max(1, int(runs))
print(f"\n{'=' * 20} BACKEND BENCHMARKING START {'=' * 20}")
print(f"Mode: {'EXTREME (Breaking Space)' if extreme else 'Standard'}")
+ print(f"Suite runs: {runs} (report = median of run medians)")
print(f"Base Memory: {get_memory_usage_mb():.2f} MB")
+ run_lists = []
+ for run_idx in range(runs):
+ if run_idx > 0:
+ self.reset_db()
+ print(f"\n{'=' * 10} Suite run {run_idx + 1}/{runs} {'=' * 10}")
+ self._run_suite(extreme=extreme)
+ run_lists.append(list(self.results))
+
+ self.results = aggregate_run_results(run_lists)
+ self._suite_runs = runs
+ self.print_summary(json_output_path=json_output_path)
+
+ def _run_suite(self, extreme=False):
self.bench_db_initialization()
if extreme:
@@ -67,8 +99,6 @@ class BackendBenchmarker:
self.bench_access_attempt_operations()
self.bench_misc_operations()
- self.print_summary(json_output_path=json_output_path)
-
def bench_extreme_message_flood(self):
"""Insert 100,000 messages with large randomized content."""
peer_hashes = [secrets.token_hex(16) for _ in range(200)]
@@ -271,16 +301,55 @@ class BackendBenchmarker:
def get_announces():
return self.db.announces.get_filtered_announces(limit=50)
- @benchmark("Trim Announces for Aspect", iterations=20)
- def trim_announces():
- return self.db.announces.trim_announces_for_aspect("lxmf.delivery", 500)
+ def _seed_announces_for_trim(count):
+ with self.db.provider:
+ for _ in range(count):
+ self.db.announces.upsert_announce(
+ {
+ "destination_hash": secrets.token_hex(16),
+ "aspect": "lxmf.delivery",
+ "identity_hash": secrets.token_hex(16),
+ "identity_public_key": "pubkey",
+ "app_data": "bench data",
+ "rssi": -50,
+ "snr": 5.0,
+ "quality": 3,
+ }
+ )
_, res = upsert_announces()
self.results.append(res)
_, res = get_announces()
self.results.append(res)
- _, res = trim_announces()
- self.results.append(res)
+
+ # Time only the trim DELETE. Re-seed between samples so later
+ # iterations are not empty no-ops after the first successful trim.
+ import gc
+
+ trim_samples = []
+ gc.collect()
+ mem0 = get_memory_usage_mb()
+ for _ in range(10):
+ _seed_announces_for_trim(200)
+ t0 = time.perf_counter()
+ self.db.announces.trim_announces_for_aspect("lxmf.delivery", 50)
+ t1 = time.perf_counter()
+ trim_samples.append((t1 - t0) * 1000.0)
+ gc.collect()
+ trim_ms = median(trim_samples)
+ print("BENCHMARK: Trim Announces for Aspect")
+ print(" Iterations: 10 (re-seeded each sample)")
+ print(f" Median Duration: {trim_ms:.3f} ms")
+ print(f" MAD: {median_abs_deviation(trim_samples, center=trim_ms):.3f} ms")
+ self.results.append(
+ BenchmarkResult(
+ "Trim Announces for Aspect",
+ trim_ms,
+ get_memory_usage_mb() - mem0,
+ samples_ms=trim_samples,
+ iterations=10,
+ )
+ )
def bench_identity_operations(self):
manager = IdentityManager(self.temp_dir)
@@ -612,14 +681,19 @@ class BackendBenchmarker:
self.results.append(res)
def print_summary(self, json_output_path=None):
+ suite_runs = getattr(self, "_suite_runs", 1)
print(f"\n{'=' * 20} BENCHMARK SUMMARY {'=' * 20}")
- print(f"{'Benchmark Name':40} | {'Avg Time':10} | {'Mem Delta':10}")
- print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 10}")
+ print(f"Aggregated over {suite_runs} suite run(s) (median of medians)")
+ print(
+ f"{'Benchmark Name':40} | {'Median':10} | {'MAD':8} | {'CV':6} | {'Mem':10}"
+ )
+ print(f"{'-' * 40}-|-{'-' * 10}-|-{'-' * 8}-|-{'-' * 6}-|-{'-' * 10}")
for r in self.results:
print(
- f"{r.name:40} | {r.duration_ms:8.2f} ms | {r.memory_delta_mb:8.2f} MB",
+ f"{r.name:40} | {r.duration_ms:8.3f} ms | "
+ f"{r.mad_ms:6.3f} | {r.cv:5.2f} | {r.memory_delta_mb:8.2f} MB",
)
- print(f"{'=' * 59}")
+ print(f"{'=' * 90}")
print(f"Final Memory Usage: {get_memory_usage_mb():.2f} MB")
if json_output_path:
@@ -630,7 +704,10 @@ class BackendBenchmarker:
"name": r.name,
"unit": "ms",
"value": round(r.duration_ms, 3),
- "extra": f"Memory delta: {r.memory_delta_mb:.2f} MB",
+ "extra": (
+ f"mad={r.mad_ms:.3f} cv={r.cv:.3f} runs={suite_runs} "
+ f"mem_delta_mb={r.memory_delta_mb:.2f}"
+ ),
}
for r in self.results
]
@@ -654,10 +731,20 @@ if __name__ == "__main__":
default=None,
help="Write benchmark results as github-action-benchmark customSmallerIsBetter JSON to PATH",
)
+ parser.add_argument(
+ "--runs",
+ type=int,
+ default=1,
+ help="Repeat the full suite N times and report median of run medians (CI uses 3)",
+ )
args = parser.parse_args()
bench = BackendBenchmarker()
try:
- bench.run_all(extreme=args.extreme, json_output_path=args.json_output)
+ bench.run_all(
+ extreme=args.extreme,
+ json_output_path=args.json_output,
+ runs=args.runs,
+ )
finally:
bench.cleanup()
diff --git a/tests/backend/test_api_json_contracts.py b/tests/backend/test_api_json_contracts.py
index 96ad3535..ec0413c3 100644
--- a/tests/backend/test_api_json_contracts.py
+++ b/tests/backend/test_api_json_contracts.py
@@ -146,3 +146,41 @@ def test_auth_status_schema_accepts_error_envelope():
"error": "decryption failed",
}
assert_matches_schema(sample, AUTH_STATUS_SCHEMA)
+
+
+def test_auth_status_schema_accepts_starting_envelope():
+ sample = {
+ "auth_enabled": False,
+ "password_set": False,
+ "authenticated": False,
+ "network_ready": False,
+ "status": "starting",
+ "stage": "rns",
+ }
+ assert_matches_schema(sample, AUTH_STATUS_SCHEMA)
+
+
+def test_status_schema_accepts_starting_and_failed_envelopes():
+ starting = {
+ "status": "starting",
+ "stage": "identity",
+ "network_ready": False,
+ "listen_host": "127.0.0.1",
+ "listen_port": 9337,
+ "https_enabled": True,
+ "is_loopback_bind": True,
+ "plugins_enabled": True,
+ "landlock_kernel_supported": False,
+ "landlock_requested": False,
+ "landlock_auto_enabled": False,
+ "landlock_disabled_by_env": False,
+ "landlock_active": False,
+ }
+ assert_matches_schema(starting, API_V1_STATUS_SCHEMA)
+ failed = {
+ **starting,
+ "status": "failed",
+ "stage": "failed",
+ "error": "RNS init failed",
+ }
+ assert_matches_schema(failed, API_V1_STATUS_SCHEMA)
diff --git a/tests/backend/test_benchmark_gate.py b/tests/backend/test_benchmark_gate.py
new file mode 100644
index 00000000..ad32aea7
--- /dev/null
+++ b/tests/backend/test_benchmark_gate.py
@@ -0,0 +1,236 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import os
+import tempfile
+import unittest
+
+from tests.backend.benchmarking_utils import (
+ BenchmarkResult,
+ adaptive_alert_ratio,
+ aggregate_run_results,
+ coefficient_of_variation,
+ median,
+ median_abs_deviation,
+ parse_extra_stats,
+ should_alert_regression,
+)
+from tests.backend.compare_benchmarks import (
+ compare,
+ main as compare_main,
+ update_baseline,
+)
+
+
+class TestBenchmarkStats(unittest.TestCase):
+ def test_median_odd_even(self):
+ self.assertEqual(median([3, 1, 2]), 2)
+ self.assertEqual(median([4, 1, 3, 2]), 2.5)
+
+ def test_mad_and_cv(self):
+ samples = [10.0, 10.0, 10.0, 12.0, 8.0]
+ self.assertEqual(median_abs_deviation(samples), 0.0)
+ self.assertGreater(coefficient_of_variation(samples), 0.0)
+ self.assertEqual(coefficient_of_variation([5.0]), 0.0)
+
+ def test_aggregate_run_results_median_of_medians(self):
+ r1 = BenchmarkResult("A", 10.0, 0.1, samples_ms=[9, 10, 11])
+ r2 = BenchmarkResult("A", 20.0, 0.2, samples_ms=[19, 20, 21])
+ r3 = BenchmarkResult("A", 12.0, 0.15, samples_ms=[11, 12, 13])
+ b1 = BenchmarkResult("B", 1.0, 0.0, samples_ms=[1.0])
+ b2 = BenchmarkResult("B", 3.0, 0.0, samples_ms=[3.0])
+ b3 = BenchmarkResult("B", 2.0, 0.0, samples_ms=[2.0])
+ out = aggregate_run_results([[r1, b1], [r2, b2], [r3, b3]])
+ by_name = {r.name: r for r in out}
+ self.assertEqual(by_name["A"].duration_ms, 12.0)
+ self.assertEqual(by_name["B"].duration_ms, 2.0)
+ self.assertEqual(len(by_name["A"].samples_ms), 9)
+
+ def test_adaptive_alert_ratio_widens_for_tiny_baselines(self):
+ self.assertEqual(adaptive_alert_ratio(0.2), 4.0)
+ self.assertEqual(adaptive_alert_ratio(2.0), 2.5)
+ self.assertEqual(adaptive_alert_ratio(10.0), 2.0)
+ self.assertEqual(adaptive_alert_ratio(50.0), 1.5)
+
+ def test_should_alert_skips_noise_floor(self):
+ alert, reason = should_alert_regression(0.4, 0.15)
+ self.assertFalse(alert)
+ self.assertIn("noise floor", reason)
+
+ def test_should_alert_skips_tiny_abs_delta(self):
+ # 3x ratio but only +0.4 ms absolute — not actionable
+ alert, reason = should_alert_regression(0.6, 0.2, noise_floor_ms=0.1)
+ self.assertFalse(alert)
+ self.assertIn("abs delta", reason)
+
+ def test_should_alert_real_regression(self):
+ # Large baseline, clear absolute and ratio regression
+ alert, reason = should_alert_regression(40.0, 10.0)
+ self.assertTrue(alert)
+ self.assertIn("slower", reason)
+
+ def test_should_alert_within_adaptive_threshold(self):
+ # 1.8x on a 10 ms baseline — adaptive threshold is 2.0x
+ alert, reason = should_alert_regression(18.0, 10.0)
+ self.assertFalse(alert)
+ self.assertIn("within adaptive", reason)
+
+ def test_should_alert_widens_when_cv_high(self):
+ # Would alert at 2.1x on 10 ms baseline (threshold 2.0), but high CV
+ # bumps threshold to 2.5 so this stays quiet.
+ alert, _ = should_alert_regression(
+ 21.0,
+ 10.0,
+ current_cv=0.5,
+ previous_cv=0.1,
+ )
+ self.assertFalse(alert)
+
+ def test_parse_extra_stats(self):
+ stats = parse_extra_stats("mad=0.012 cv=0.150 runs=3 mem_delta_mb=0.10")
+ self.assertAlmostEqual(stats["mad"], 0.012)
+ self.assertAlmostEqual(stats["cv"], 0.15)
+ self.assertAlmostEqual(stats["runs"], 3.0)
+
+
+class TestCompareBenchmarks(unittest.TestCase):
+ def _write(self, path, entries):
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(entries, f)
+
+ def test_compare_flags_real_regression_only(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ current = os.path.join(tmp, "current.json")
+ previous = os.path.join(tmp, "previous.json")
+ self._write(
+ current,
+ [
+ {
+ "name": "Tiny Noise",
+ "unit": "ms",
+ "value": 0.4,
+ "extra": "cv=0.1",
+ },
+ {
+ "name": "Real Slowdown",
+ "unit": "ms",
+ "value": 40.0,
+ "extra": "cv=0.05",
+ },
+ {
+ "name": "Ratio Noise",
+ "unit": "ms",
+ "value": 0.455,
+ "extra": "cv=0.2",
+ },
+ ],
+ )
+ self._write(
+ previous,
+ [
+ {
+ "name": "Tiny Noise",
+ "unit": "ms",
+ "value": 0.15,
+ "extra": "cv=0.1",
+ },
+ {
+ "name": "Real Slowdown",
+ "unit": "ms",
+ "value": 10.0,
+ "extra": "cv=0.05",
+ },
+ {
+ "name": "Ratio Noise",
+ "unit": "ms",
+ "value": 0.177,
+ "extra": "cv=0.2",
+ },
+ ],
+ )
+ code, rows = compare(
+ current,
+ previous,
+ noise_floor_ms=0.5,
+ min_abs_delta_ms=1.5,
+ )
+ by_name = {r["name"]: r for r in rows}
+ self.assertEqual(code, 1)
+ self.assertEqual(by_name["Real Slowdown"]["status"], "REGRESSION")
+ self.assertEqual(by_name["Tiny Noise"]["status"], "noise")
+ self.assertEqual(by_name["Ratio Noise"]["status"], "noise")
+
+ def test_compare_trim_announce_style_false_positive(self):
+ """The exact CI false-positive pattern: 0.177 -> 0.455 (2.57x)."""
+ with tempfile.TemporaryDirectory() as tmp:
+ current = os.path.join(tmp, "current.json")
+ previous = os.path.join(tmp, "previous.json")
+ self._write(
+ current,
+ [
+ {
+ "name": "Trim Announces for Aspect",
+ "unit": "ms",
+ "value": 0.455,
+ "extra": "mad=0.05 cv=0.3 runs=1",
+ }
+ ],
+ )
+ self._write(
+ previous,
+ [
+ {
+ "name": "Trim Announces for Aspect",
+ "unit": "ms",
+ "value": 0.177,
+ "extra": "mad=0.02 cv=0.2 runs=1",
+ }
+ ],
+ )
+ code, rows = compare(current, previous)
+ self.assertEqual(code, 0)
+ self.assertEqual(rows[0]["status"], "noise")
+
+ def test_update_baseline_and_reload(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ current = os.path.join(tmp, "current.json")
+ baseline = os.path.join(tmp, "cache", "benchmark-data.json")
+ self._write(
+ current,
+ [{"name": "Config Get (50 keys)", "unit": "ms", "value": 0.4}],
+ )
+ update_baseline(current, baseline)
+ self.assertTrue(os.path.isfile(baseline))
+ code, rows = compare(current, baseline)
+ self.assertEqual(code, 0)
+ self.assertEqual(rows[0]["status"], "ok")
+
+ def test_cli_exits_nonzero_on_regression(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ current = os.path.join(tmp, "current.json")
+ previous = os.path.join(tmp, "previous.json")
+ self._write(
+ current,
+ [{"name": "Create Identity", "unit": "ms", "value": 200.0}],
+ )
+ self._write(
+ previous,
+ [{"name": "Create Identity", "unit": "ms", "value": 80.0}],
+ )
+ code = compare_main(
+ [
+ "--current",
+ current,
+ "--previous",
+ previous,
+ "--noise-floor-ms",
+ "0.5",
+ "--min-abs-delta-ms",
+ "1.5",
+ ]
+ )
+ self.assertEqual(code, 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/backend/test_community_interfaces_directory.py b/tests/backend/test_community_interfaces_directory.py
index 90b193ff..b137205b 100644
--- a/tests/backend/test_community_interfaces_directory.py
+++ b/tests/backend/test_community_interfaces_directory.py
@@ -10,6 +10,8 @@ from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.community_interfaces_directory import (
+ DEFAULT_DIRECTORY_URLS,
+ DEFAULT_DISCOVERED_URL,
DEFAULT_SUBMITTED_URL,
fetch_directory_payload,
rows_from_payload,
@@ -21,6 +23,11 @@ from meshchatx.src.backend.community_interfaces_directory import (
def test_default_url_is_submitted_online():
assert "submitted" in DEFAULT_SUBMITTED_URL
assert "status=online" in DEFAULT_SUBMITTED_URL
+ assert "discovered" in DEFAULT_DISCOVERED_URL
+ assert DEFAULT_SUBMITTED_URL in DEFAULT_DIRECTORY_URLS
+ assert DEFAULT_DISCOVERED_URL in DEFAULT_DIRECTORY_URLS
+ assert "search=" not in DEFAULT_SUBMITTED_URL
+ assert "type=" not in DEFAULT_SUBMITTED_URL
def test_validate_directory_fetch_url_accepts_default_host():
diff --git a/tests/backend/test_deferred_network_startup.py b/tests/backend/test_deferred_network_startup.py
new file mode 100644
index 00000000..cc23ff83
--- /dev/null
+++ b/tests/backend/test_deferred_network_startup.py
@@ -0,0 +1,498 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import shutil
+import tempfile
+import threading
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import RNS
+from aiohttp import web
+from aiohttp.test_utils import TestClient, TestServer
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.meshchat import ReticulumMeshChat
+from tests.backend.api_json_contract_schemas import (
+ API_V1_STATUS_SCHEMA,
+ AUTH_STATUS_SCHEMA,
+ assert_matches_schema,
+)
+
+
+@pytest.fixture
+def temp_dir():
+ dir_path = tempfile.mkdtemp()
+ yield dir_path
+ shutil.rmtree(dir_path)
+
+
+@pytest.fixture
+def mock_identity():
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ return identity
+
+
+def _make_deferred_app(mock_identity, temp_dir, **kwargs):
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch.object(ReticulumMeshChat, "setup_identity"),
+ ):
+ return ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ **kwargs,
+ )
+
+
+def test_deferred_init_skips_reticulum_until_background_setup(mock_identity, temp_dir):
+ with (
+ patch("RNS.Reticulum") as mock_reticulum,
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch.object(ReticulumMeshChat, "setup_identity") as mock_setup,
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ assert app._network_ready is False
+ assert app._startup_stage == "http"
+ mock_reticulum.assert_not_called()
+ mock_setup.assert_not_called()
+
+ payload = app._startup_status_payload()
+ assert payload["status"] == "starting"
+ assert payload["network_ready"] is False
+ assert payload["stage"] == "http"
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+
+
+def test_background_network_setup_marks_ready(mock_identity, temp_dir):
+ ready = threading.Event()
+
+ def fake_setup(self, identity):
+ context = MagicMock()
+ context.running = True
+ context.config = MagicMock()
+ context.config.auth_session_secret.set = MagicMock()
+ self.current_context = context
+ ready.set()
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", fake_setup),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.session_secret_key = "test-secret"
+ app.start_network_setup_in_background()
+ assert ready.wait(timeout=5)
+ assert app.wait_until_network_ready(timeout=5)
+ payload = app._startup_status_payload()
+ assert payload["status"] == "ok"
+ assert payload["network_ready"] is True
+ assert payload["stage"] == "ready"
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+
+
+def test_create_reticulum_instance_works_off_main_thread(temp_dir):
+ from meshchatx import meshchat as meshchat_mod
+
+ result = {"error": None, "instance": None, "signal_calls": 0}
+
+ def worker():
+ real_signal = meshchat_mod.signal.signal
+
+ def boom(signum, handler):
+ result["signal_calls"] += 1
+ raise ValueError(
+ "signal only works in main thread of the main interpreter",
+ )
+
+ def ctor(config_dir, **kwargs):
+ meshchat_mod.signal.signal(meshchat_mod.signal.SIGINT, lambda *a: None)
+ return MagicMock(name="rns")
+
+ meshchat_mod.signal.signal = boom
+ try:
+ with (
+ patch.object(meshchat_mod.RNS, "Reticulum", side_effect=ctor),
+ patch(
+ "meshchatx.meshchat.threading.current_thread",
+ return_value=threading.Thread(name="worker"),
+ ),
+ ):
+ result["instance"] = meshchat_mod._create_reticulum_instance(temp_dir)
+ except Exception as exc:
+ result["error"] = exc
+ finally:
+ meshchat_mod.signal.signal = real_signal
+
+ thread = threading.Thread(target=worker)
+ thread.start()
+ thread.join(timeout=5)
+ assert result["error"] is None, result["error"]
+ assert result["instance"] is not None
+ assert result["signal_calls"] >= 1
+
+
+def test_create_reticulum_instance_main_thread_passes_loglevel(temp_dir):
+ from meshchatx import meshchat as meshchat_mod
+
+ with patch.object(meshchat_mod.RNS, "Reticulum") as mock_ctor:
+ mock_ctor.return_value = MagicMock(name="rns")
+ with patch(
+ "meshchatx.meshchat.threading.current_thread",
+ return_value=threading.main_thread(),
+ ):
+ meshchat_mod._create_reticulum_instance(temp_dir, loglevel=3)
+ mock_ctor.assert_called_once_with(temp_dir, loglevel=3)
+
+
+def test_immediate_init_still_sets_up_network(mock_identity, temp_dir):
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch.object(ReticulumMeshChat, "setup_identity") as mock_setup,
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=False,
+ )
+ mock_setup.assert_called_once_with(mock_identity)
+ assert app._network_ready is True
+ assert app._startup_stage == "ready"
+
+
+def test_network_setup_failure_sets_failed_status(mock_identity, temp_dir):
+ def boom(self, identity):
+ raise RuntimeError("RNS init exploded")
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", boom),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.start_network_setup_in_background()
+ deadline = time.monotonic() + 5
+ while time.monotonic() < deadline and app._startup_stage != "failed":
+ time.sleep(0.02)
+ payload = app._startup_status_payload()
+ assert payload["status"] == "failed"
+ assert payload["network_ready"] is False
+ assert payload["stage"] == "failed"
+ assert "RNS init exploded" in payload.get("error", "")
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+ assert app.wait_until_network_ready(timeout=0.05) is False
+
+
+def test_double_start_network_setup_is_idempotent(mock_identity, temp_dir):
+ calls = []
+ gate = threading.Event()
+ entered = threading.Event()
+
+ def slow_setup(self, identity):
+ calls.append(identity)
+ entered.set()
+ gate.wait(timeout=2)
+ context = MagicMock()
+ context.running = True
+ context.config = MagicMock()
+ context.config.auth_session_secret.set = MagicMock()
+ self.current_context = context
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", slow_setup),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.session_secret_key = "secret"
+ app.start_network_setup_in_background()
+ assert entered.wait(timeout=2)
+ app.start_network_setup_in_background()
+ app.start_network_setup_in_background()
+ gate.set()
+ assert app.wait_until_network_ready(timeout=5)
+ assert len(calls) == 1
+
+
+def test_start_without_pending_identity_raises(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ app._pending_identity = None
+ with pytest.raises(RuntimeError, match="No identity"):
+ app.start_network_setup_in_background()
+
+
+def test_wait_until_network_ready_true_when_already_ready(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ context = MagicMock()
+ context.running = True
+ app.current_context = context
+ app._mark_network_ready()
+ assert app.wait_until_network_ready(timeout=0) is True
+
+
+def test_startup_status_payload_stages(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ for stage in ("http", "starting", "rns", "identity"):
+ app._set_startup_stage(stage)
+ payload = app._startup_status_payload()
+ assert payload["status"] == "starting"
+ assert payload["stage"] == stage
+ assert payload["network_ready"] is False
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+
+
+def test_session_secret_persisted_after_background_setup(mock_identity, temp_dir):
+ secret_set = MagicMock()
+
+ def fake_setup(self, identity):
+ context = MagicMock()
+ context.running = True
+ context.config = MagicMock()
+ context.config.auth_session_secret.set = secret_set
+ self.current_context = context
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", fake_setup),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.session_secret_key = "persisted-secret"
+ app.start_network_setup_in_background()
+ assert app.wait_until_network_ready(timeout=5)
+ secret_set.assert_called_once_with("persisted-secret")
+
+
+def _route_handler(app: ReticulumMeshChat, path: str, method: str):
+ for route in app.get_routes():
+ if route.path == path and route.method == method:
+ return route.handler
+ return None
+
+
+@pytest.mark.asyncio
+async def test_status_endpoint_while_starting(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ handler = _route_handler(app, "/api/v1/status", "GET")
+ response = await handler(MagicMock())
+ data = json.loads(response.body)
+ assert data["status"] == "starting"
+ assert data["network_ready"] is False
+ assert_matches_schema(data, API_V1_STATUS_SCHEMA)
+
+
+@pytest.mark.asyncio
+async def test_auth_status_while_starting(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ handler = _route_handler(app, "/api/v1/auth/status", "GET")
+ response = await handler(MagicMock())
+ data = json.loads(response.body)
+ assert data["network_ready"] is False
+ assert data["status"] == "starting"
+ assert data["authenticated"] is False
+ assert data["password_set"] is False
+ assert_matches_schema(data, AUTH_STATUS_SCHEMA)
+
+
+@pytest.mark.asyncio
+async def test_auth_middleware_allows_status_and_static_while_starting(
+ mock_identity,
+ temp_dir,
+):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ routes = web.RouteTableDef()
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ aio_app.add_routes(routes)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ status = await client.get("/api/v1/status")
+ assert status.status == 200
+ body = await status.json()
+ assert body["status"] == "starting"
+
+ auth = await client.get("/api/v1/auth/status")
+ assert auth.status == 200
+ auth_body = await auth.json()
+ assert auth_body["network_ready"] is False
+
+ blocked = await client.get("/api/v1/config")
+ assert blocked.status == 503
+ blocked_body = await blocked.json()
+ assert blocked_body["status"] == "starting"
+ assert blocked_body["network_ready"] is False
+
+
+@pytest.mark.asyncio
+async def test_auth_middleware_allows_csrf_while_starting(mock_identity, temp_dir):
+ app = _make_deferred_app(mock_identity, temp_dir)
+ routes = web.RouteTableDef()
+ auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw, csrf_mw, ip_mw])
+ aio_app.add_routes(routes)
+
+ with patch(
+ "meshchatx.meshchat.get_session",
+ new_callable=AsyncMock,
+ ) as mock_session:
+ mock_session.return_value = {}
+ async with TestClient(TestServer(aio_app)) as client:
+ csrf = await client.get("/api/v1/auth/csrf")
+ assert csrf.status == 200
+
+
+@given(
+ status=st.sampled_from(["ok", "starting", "failed"]),
+ stage=st.sampled_from(["http", "starting", "rns", "identity", "ready", "failed"]),
+ network_ready=st.booleans(),
+ error=st.one_of(st.none(), st.text(max_size=200)),
+)
+@settings(deadline=None, max_examples=80)
+def test_status_schema_fuzz_valid_envelopes(status, stage, network_ready, error):
+ sample = {
+ "status": status,
+ "stage": stage,
+ "network_ready": network_ready,
+ "listen_host": "127.0.0.1",
+ "listen_port": 9337,
+ "https_enabled": True,
+ "is_loopback_bind": True,
+ "plugins_enabled": True,
+ "landlock_kernel_supported": False,
+ "landlock_requested": False,
+ "landlock_auto_enabled": False,
+ "landlock_disabled_by_env": False,
+ "landlock_active": False,
+ }
+ if error is not None:
+ sample["error"] = error
+ assert_matches_schema(sample, API_V1_STATUS_SCHEMA)
+
+
+@given(
+ stage=st.sampled_from(["http", "starting", "rns", "identity"]),
+ listen_port=st.one_of(st.none(), st.integers(min_value=1, max_value=65535)),
+ https_enabled=st.booleans(),
+)
+@settings(deadline=None, max_examples=40, suppress_health_check=[HealthCheck.too_slow])
+def test_startup_status_payload_fuzz_stages(stage, listen_port, https_enabled):
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ dir_path = tempfile.mkdtemp()
+ try:
+ app = _make_deferred_app(identity, dir_path)
+ app.listen_host = "127.0.0.1"
+ app.listen_port = listen_port
+ app.use_https = https_enabled
+ app._set_startup_stage(stage)
+ payload = app._startup_status_payload()
+ assert payload["status"] == "starting"
+ assert payload["stage"] == stage
+ assert payload["network_ready"] is False
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+ finally:
+ shutil.rmtree(dir_path, ignore_errors=True)
+
+
+@given(error=st.text(min_size=0, max_size=500))
+@settings(deadline=None, max_examples=40, suppress_health_check=[HealthCheck.too_slow])
+def test_failed_status_payload_fuzz_errors(error):
+ identity = MagicMock(spec=RNS.Identity)
+ identity.hash = b"test_hash_32_bytes_long_01234567"
+ identity.hexhash = identity.hash.hex()
+ dir_path = tempfile.mkdtemp()
+ try:
+ app = _make_deferred_app(identity, dir_path)
+ app._set_startup_stage("failed", error)
+ payload = app._startup_status_payload()
+ assert payload["status"] == "failed"
+ assert payload["network_ready"] is False
+ if error:
+ assert payload["error"] == error
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
+ finally:
+ shutil.rmtree(dir_path, ignore_errors=True)
+
+
+def test_concurrent_status_payload_reads_during_setup(mock_identity, temp_dir):
+ gate = threading.Event()
+ results = []
+
+ def fake_setup(self, identity):
+ self._set_startup_stage("rns")
+ gate.wait(timeout=2)
+ self._set_startup_stage("identity")
+ context = MagicMock()
+ context.running = True
+ context.config = MagicMock()
+ context.config.auth_session_secret.set = MagicMock()
+ self.current_context = context
+
+ with (
+ patch("meshchatx.meshchat.AsyncUtils.ensure_background_loop"),
+ patch("meshchatx.meshchat.AsyncUtils.run_async"),
+ patch.object(ReticulumMeshChat, "setup_identity", fake_setup),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_identity,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ defer_network_setup=True,
+ )
+ app.session_secret_key = "secret"
+ app.start_network_setup_in_background()
+
+ def reader():
+ for _ in range(20):
+ payload = app._startup_status_payload()
+ results.append(payload)
+ assert payload["status"] in ("starting", "ok", "failed")
+ assert "stage" in payload
+ assert "network_ready" in payload
+ time.sleep(0.005)
+
+ threads = [threading.Thread(target=reader) for _ in range(4)]
+ for t in threads:
+ t.start()
+ time.sleep(0.05)
+ gate.set()
+ for t in threads:
+ t.join(timeout=5)
+ assert app.wait_until_network_ready(timeout=5)
+ assert results
+ for payload in results:
+ assert_matches_schema(payload, API_V1_STATUS_SCHEMA)
diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index 4fca324a..e200f3c1 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -78,6 +78,102 @@ class TestPluginManagerInstall:
assert result["paths"][0]["destination_hash"] == "abc123"
assert result["paths"][0]["interface"] == "RNode LoRa"
+ def test_rns_link_capabilities_require_manifest_grant(self, tmp_path):
+ class FakeLinkManager:
+ async def open_link(self, *_args, **_kwargs):
+ return object(), False, None
+
+ def identify(self, *_args, **_kwargs):
+ return True, None
+
+ def close(self, *_args, **_kwargs):
+ return True
+
+ class FakeApp:
+ reticulum = object()
+ rns_link_manager = FakeLinkManager()
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ manager.enable(plugin_id)
+ with pytest.raises(PermissionError):
+ manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": "aa" * 16, "aspect": "microrn.mgmt"},
+ )
+
+ record = manager._plugins[plugin_id]
+ record.manifest.setdefault("permissions", {})["managers"] = [
+ "destinationPath.read",
+ "rnsLink.open",
+ "rnsLink.close",
+ ]
+ record.granted_permissions = [
+ "managers:destinationPath.read",
+ "managers:rnsLink.open",
+ "managers:rnsLink.close",
+ ]
+ opened = manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": "aa" * 16, "aspect": "microrn.mgmt"},
+ )
+ assert opened["ok"] is True
+ closed = manager.call_manager(
+ plugin_id,
+ "rnsLink.close",
+ {"destination_hash": "aa" * 16, "aspect": "microrn.mgmt"},
+ )
+ assert closed["ok"] is True
+
+ def test_rns_link_event_hook_dispatches(self, tmp_path):
+ events = []
+
+ class FakeApp:
+ plugins_enabled = True
+
+ def websocket_broadcast(self, _message):
+ return None
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ manager.enable(plugin_id)
+ record = manager._plugins[plugin_id]
+ record.manifest.setdefault("permissions", {})["hooks"] = [
+ "announce.received",
+ "rns.link.event",
+ ]
+ record.granted_permissions = [
+ "hooks:announce.received",
+ "hooks:rns.link.event",
+ ]
+ manager.dispatch_hook = lambda pid, hook, payload: events.append(
+ (pid, hook, payload)
+ )
+ manager.on_rns_link_event(
+ {
+ "type": "rns.link.event",
+ "event": "link_closed",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ }
+ )
+ assert events == [
+ (
+ plugin_id,
+ "rns.link.event",
+ {
+ "event": "link_closed",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "payload_b64": None,
+ },
+ )
+ ]
+
def test_manifest_validation_rejects_invalid_id(self, tmp_path):
manager = _make_manager(tmp_path)
plugin_dir = os.path.join(tmp_path, "bad-plugin")
diff --git a/tests/backend/test_plugin_permissions.py b/tests/backend/test_plugin_permissions.py
new file mode 100644
index 00000000..aeec1cc0
--- /dev/null
+++ b/tests/backend/test_plugin_permissions.py
@@ -0,0 +1,151 @@
+# SPDX-License-Identifier: 0BSD
+
+import io
+import json
+import os
+import zipfile
+
+import pytest
+
+from meshchatx.src.backend.plugin_permissions import (
+ collect_network_endpoints,
+ declared_permission_ids,
+ extract_urls_from_text,
+ normalize_granted_permissions,
+ requires_network_fetch,
+ validate_declared_permissions,
+)
+
+
+def test_declared_permission_ids_and_validation():
+ manifest = {
+ "permissions": {
+ "hooks": ["announce.received"],
+ "managers": ["destinationPath.read"],
+ "storage": "isolated",
+ "network": "fetch",
+ },
+ "network": {"endpoints": ["https://example.com/api"]},
+ }
+ validate_declared_permissions(manifest)
+ ids = declared_permission_ids(manifest)
+ assert "hooks:announce.received" in ids
+ assert "managers:destinationPath.read" in ids
+ assert "storage:isolated" in ids
+ assert "network:fetch" in ids
+
+
+def test_unknown_permission_rejected():
+ with pytest.raises(ValueError, match="unknown manager"):
+ validate_declared_permissions(
+ {"permissions": {"managers": ["not.a.real.capability"]}}
+ )
+
+
+def test_normalize_granted_subset():
+ declared = ["hooks:announce.received", "network:fetch", "storage:isolated"]
+ granted = normalize_granted_permissions(
+ declared, ["network:fetch", "hooks:announce.received", "network:fetch", "x"]
+ )
+ assert granted == ["network:fetch", "hooks:announce.received"]
+
+
+def test_extract_and_collect_network_endpoints(tmp_path):
+ text = 'const url = "https://api.example.com/v1"; fetch("http://localhost/ignore");'
+ assert extract_urls_from_text(text) == ["https://api.example.com/v1"]
+
+ plugin_dir = tmp_path / "plugin"
+ plugin_dir.mkdir()
+ (plugin_dir / "frontend").mkdir()
+ (plugin_dir / "frontend" / "main.js").write_text(
+ 'fetch("https://translate.example.org/translate")',
+ encoding="utf-8",
+ )
+ manifest = {
+ "permissions": {"network": "fetch"},
+ "network": {
+ "endpoints": [
+ "https://libretranslate.com/",
+ "User-configured LibreTranslate instance URL",
+ ]
+ },
+ }
+ endpoints = collect_network_endpoints(manifest, str(plugin_dir))
+ assert "https://libretranslate.com/" in endpoints
+ assert "User-configured LibreTranslate instance URL" in endpoints
+ assert any("translate.example.org" in item for item in endpoints)
+ assert requires_network_fetch(manifest, endpoints) is True
+
+
+def test_preview_and_install_with_denied_network(tmp_path):
+ from meshchatx.src.backend.plugin_manager import PluginManager
+
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "frontend").mkdir()
+ (source / "frontend" / "main.js").write_text(
+ 'export async function activate(){ fetch("https://evil.example/x") }',
+ encoding="utf-8",
+ )
+ manifest = {
+ "id": "com.example.network-demo",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Network Demo",
+ "frontend": {"entry": "frontend/main.js", "type": "js"},
+ "permissions": {
+ "hooks": ["announce.received"],
+ "storage": "isolated",
+ "network": "fetch",
+ },
+ "network": {"endpoints": ["https://evil.example/"]},
+ }
+ (source / "plugin.json").write_text(json.dumps(manifest), encoding="utf-8")
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as archive:
+ for root, _dirs, files in os.walk(source):
+ for name in files:
+ path = os.path.join(root, name)
+ archive.write(path, os.path.relpath(path, source))
+ payload = buf.getvalue()
+
+ manager = PluginManager(str(tmp_path / "storage"))
+ preview = manager.preview_from_zip_bytes(payload)
+ assert preview["requires_network_fetch"] is True
+ assert "network:fetch" in preview["permissions"]
+ assert any("evil.example" in item for item in preview["network_endpoints"])
+
+ installed = manager.install_from_zip_bytes(
+ payload,
+ granted_permissions=["hooks:announce.received", "storage:isolated"],
+ )
+ assert "network:fetch" not in installed["granted_permissions"]
+ assert manager.network_fetch_allowed(installed["id"]) is False
+ manager.enable(installed["id"])
+ # storage was granted; network was denied
+ manager.storage_set(installed["id"], "k", "v")
+ assert manager.storage_get(installed["id"], "k") == "v"
+
+
+def test_storage_denied_without_grant(tmp_path):
+ from meshchatx.src.backend.plugin_manager import PluginManager
+
+ source = tmp_path / "src"
+ source.mkdir()
+ (source / "plugin.json").write_text(
+ json.dumps(
+ {
+ "id": "com.example.storage-demo",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "permissions": {"storage": "isolated"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ manager = PluginManager(str(tmp_path / "storage"))
+ installed = manager.install_from_directory(str(source), granted_permissions=[])
+ manager.enable(installed["id"])
+ with pytest.raises(PermissionError):
+ manager.storage_set(installed["id"], "k", "v")
diff --git a/tests/backend/test_rns_link_fuzzing.py b/tests/backend/test_rns_link_fuzzing.py
new file mode 100644
index 00000000..2af32efd
--- /dev/null
+++ b/tests/backend/test_rns_link_fuzzing.py
@@ -0,0 +1,364 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Hypothesis property/fuzz coverage for the generic RNS Link API."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from hypothesis import given, settings
+from hypothesis import strategies as st
+
+from meshchatx.meshchat import ReticulumMeshChat
+from meshchatx.src.backend import rns_link_manager as rlm
+from meshchatx.src.backend.websocket_config_guard import (
+ WEBSOCKET_MUTATOR_TYPES,
+ websocket_type_requires_auth,
+)
+from tests.backend.ws_json_contract_schemas import assert_ws_message_matches_schema
+
+
+st_aspect = st.text(
+ alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789._-"),
+ min_size=1,
+ max_size=64,
+).filter(lambda value: any(ch.isalnum() for ch in value))
+
+st_hex_hash = st.binary(min_size=16, max_size=16).map(lambda b: b.hex())
+
+st_request_id = st.text(min_size=1, max_size=64)
+
+st_nasty_payload = st.one_of(
+ st.binary(min_size=0, max_size=256),
+ st.text(min_size=0, max_size=128).map(lambda s: s.encode("utf-8", errors="ignore")),
+)
+
+
+@pytest.fixture(autouse=True)
+def clear_link_cache():
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links.clear()
+ rlm._link_failure_counts.clear()
+ yield
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links.clear()
+ rlm._link_failure_counts.clear()
+
+
+def _make_app():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._rns_link_tasks = {}
+ app._rns_request_receipts = {}
+ app.rns_link_manager = MagicMock()
+ return app
+
+
+def _capture_client():
+ client = MagicMock()
+ sent: list[dict] = []
+
+ async def capture_send(payload: str):
+ sent.append(json.loads(payload))
+
+ client.send_str = AsyncMock(side_effect=capture_send)
+ return client, sent
+
+
+@given(aspect=st.text(min_size=0, max_size=80))
+@settings(max_examples=80, deadline=None)
+def test_split_aspect_never_crashes(aspect):
+ try:
+ app_name, sub = rlm._split_aspect(aspect)
+ except ValueError:
+ return
+ assert isinstance(app_name, str) and app_name
+ assert isinstance(sub, list)
+ assert all(isinstance(part, str) and part for part in sub)
+
+
+@given(aspect=st_aspect, dest=st.binary(min_size=16, max_size=16))
+@settings(max_examples=60, deadline=None)
+def test_cache_roundtrip_property(aspect, dest):
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ rlm._cache_link_if_active(aspect, dest, link)
+ assert rlm.get_cached_active_link(aspect, dest) is link
+ rlm._uncache_link_if_matches(aspect, dest, link)
+ assert rlm.get_cached_active_link(aspect, dest) is None
+
+
+@given(
+ dest=st.binary(min_size=16, max_size=16),
+ failures=st.integers(min_value=1, max_value=8),
+)
+@settings(max_examples=40, deadline=None)
+def test_failure_recycle_threshold_property(dest, failures):
+ key = ("app.aspect", dest)
+ link = MagicMock()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[key] = link
+ rlm._link_failure_counts.pop(key, None)
+ recycled_at = None
+ for i in range(failures):
+ _count, recycled = rlm._record_failure_and_maybe_recycle(key)
+ if recycled:
+ recycled_at = i + 1
+ break
+ if failures >= rlm._LINK_RECYCLE_FAILURE_THRESHOLD:
+ assert recycled_at == rlm._LINK_RECYCLE_FAILURE_THRESHOLD
+ assert key not in rlm.rns_cached_links
+ link.teardown.assert_called()
+ else:
+ assert recycled_at is None
+ assert key in rlm.rns_cached_links
+
+
+def test_rns_link_mutators_require_auth():
+ for msg_type in (
+ "rns.link.open",
+ "rns.link.identify",
+ "rns.link.request",
+ "rns.link.send",
+ "rns.link.close",
+ ):
+ assert msg_type in WEBSOCKET_MUTATOR_TYPES
+ assert websocket_type_requires_auth(msg_type) is True
+
+
+@given(
+ dest_hex=st.one_of(st_hex_hash, st.text(min_size=0, max_size=40), st.none()),
+ aspect=st.one_of(st_aspect, st.text(min_size=0, max_size=40), st.none()),
+ request_id=st_request_id,
+)
+@settings(max_examples=50, deadline=None)
+def test_rns_link_open_parse_failures_never_raise(dest_hex, aspect, request_id):
+ async def _run():
+ app = _make_app()
+ app.rns_link_manager.open_link = AsyncMock(
+ return_value=(MagicMock(), False, None)
+ )
+ client, sent = _capture_client()
+ await app._handle_rns_link_open(
+ client,
+ {
+ "destination_hash": dest_hex,
+ "aspect": aspect,
+ "request_id": request_id,
+ },
+ )
+ assert sent
+ for payload in sent:
+ assert payload["type"] == "rns.link.open"
+ assert_ws_message_matches_schema(payload)
+
+ asyncio.run(_run())
+
+
+@given(
+ dest_hex=st_hex_hash,
+ aspect=st_aspect,
+ payload=st_nasty_payload,
+ request_id=st.text(min_size=1, max_size=24),
+)
+@settings(max_examples=40, deadline=None)
+def test_rns_link_send_fuzz_never_raises(dest_hex, aspect, payload, request_id):
+ async def _run():
+ app = _make_app()
+ app.rns_link_manager.send_packet = MagicMock(return_value=(True, None))
+ client, sent = _capture_client()
+ await app._handle_rns_link_send(
+ client,
+ {
+ "destination_hash": dest_hex,
+ "aspect": aspect,
+ "request_id": request_id,
+ "payload_b64": base64.b64encode(payload).decode("ascii"),
+ },
+ )
+ assert sent
+ assert sent[-1]["type"] == "rns.link.send"
+ assert_ws_message_matches_schema(sent[-1])
+
+ asyncio.run(_run())
+
+
+@given(
+ dest_hex=st_hex_hash,
+ aspect=st_aspect,
+ path=st.one_of(st.text(min_size=0, max_size=64), st.none()),
+ data_b64=st.one_of(
+ st.none(),
+ st.just(""),
+ st.just("!!!not-base64!!!"),
+ st.just("%%%%"),
+ st.binary(min_size=0, max_size=64).map(
+ lambda b: base64.b64encode(b).decode("ascii")
+ ),
+ ),
+ request_id=st.text(min_size=1, max_size=24),
+)
+@settings(max_examples=40, deadline=None)
+def test_rns_link_request_fuzz_never_raises(
+ dest_hex, aspect, path, data_b64, request_id
+):
+ async def _run():
+ app = _make_app()
+ app.rns_link_manager.open_link = AsyncMock(
+ return_value=(None, False, "no_path_to_destination")
+ )
+ client, sent = _capture_client()
+ await app._handle_rns_link_request(
+ client,
+ {
+ "destination_hash": dest_hex,
+ "aspect": aspect,
+ "path": path,
+ "data_b64": data_b64,
+ "request_id": request_id,
+ },
+ )
+ assert sent
+ for payload in sent:
+ assert payload["type"] == "rns.link.request"
+ assert_ws_message_matches_schema(payload)
+
+ asyncio.run(_run())
+
+
+@pytest.mark.asyncio
+async def test_rns_link_open_success_contract():
+ app = _make_app()
+ app.rns_link_manager.open_link = AsyncMock(return_value=(MagicMock(), True, None))
+ client, sent = _capture_client()
+ await app._handle_rns_link_open(
+ client,
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "req-open",
+ "auto_identify": True,
+ },
+ )
+ success = [p for p in sent if p.get("status") == "success"]
+ assert success
+ assert_ws_message_matches_schema(success[-1])
+ assert success[-1]["identified"] is True
+
+
+@pytest.mark.asyncio
+async def test_rns_link_close_and_identify_contracts():
+ app = _make_app()
+ app.rns_link_manager.identify = MagicMock(return_value=(True, None))
+ app.rns_link_manager.close = MagicMock(return_value=True)
+ client, sent = _capture_client()
+ await app._handle_rns_link_identify(
+ client,
+ {
+ "destination_hash": "bb" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "req-id",
+ },
+ )
+ await app._handle_rns_link_close(
+ client,
+ {
+ "destination_hash": "bb" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "req-close",
+ },
+ )
+ assert {p["type"] for p in sent} == {"rns.link.identify", "rns.link.close"}
+ for payload in sent:
+ assert_ws_message_matches_schema(payload)
+ assert payload["status"] == "success"
+
+
+@pytest.mark.asyncio
+async def test_rns_link_invalid_payload_b64():
+ app = _make_app()
+ client, sent = _capture_client()
+ await app._handle_rns_link_send(
+ client,
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "bad-b64",
+ "payload_b64": "%%%",
+ },
+ )
+ assert sent[-1]["status"] == "failure"
+ assert sent[-1]["failure_reason"] == "invalid_payload_b64"
+
+
+@pytest.mark.asyncio
+async def test_rns_link_request_missing_path():
+ app = _make_app()
+ client, sent = _capture_client()
+ await app._handle_rns_link_request(
+ client,
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "no-path",
+ },
+ )
+ assert sent[-1]["status"] == "failure"
+ assert sent[-1]["failure_reason"] == "missing_path"
+
+
+def _run_async_immediate(coro):
+ return asyncio.create_task(coro)
+
+
+@pytest.mark.asyncio
+async def test_rns_link_mutator_rejected_without_auth(mock_app):
+ mock_app.config.auth_enabled.set(True)
+ mock_app.config.auth_password_hash.set("hash")
+ client = MagicMock()
+ client.request = MagicMock()
+ client.send_str = AsyncMock()
+
+ with (
+ patch.object(mock_app, "_websocket_session_authorized", return_value=False),
+ patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=_run_async_immediate,
+ ),
+ ):
+ await mock_app.on_websocket_data_received(
+ client,
+ {
+ "type": "rns.link.open",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "request_id": "auth-denied",
+ },
+ )
+ await asyncio.sleep(0)
+
+ client.send_str.assert_awaited()
+ payload = client.send_str.await_args.args[0]
+ assert "Authentication required" in payload
+
+
+@given(
+ event=st.sampled_from(["packet_received", "link_closed"]),
+ dest=st_hex_hash,
+ aspect=st_aspect,
+ payload=st.one_of(st.none(), st.binary(min_size=0, max_size=32)),
+)
+@settings(max_examples=40, deadline=None)
+def test_rns_link_event_schema_property(event, dest, aspect, payload):
+ message = {
+ "type": "rns.link.event",
+ "event": event,
+ "destination_hash": dest,
+ "aspect": aspect,
+ }
+ if payload is not None:
+ message["payload_b64"] = base64.b64encode(payload).decode("ascii")
+ assert_ws_message_matches_schema(message)
diff --git a/tests/backend/test_rns_link_manager.py b/tests/backend/test_rns_link_manager.py
new file mode 100644
index 00000000..c35cd043
--- /dev/null
+++ b/tests/backend/test_rns_link_manager.py
@@ -0,0 +1,453 @@
+# SPDX-License-Identifier: 0BSD
+
+import asyncio
+import base64
+import threading
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.src.backend import rns_link_manager as rlm
+
+
+@pytest.fixture(autouse=True)
+def clear_link_cache():
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links.clear()
+ rlm._link_failure_counts.clear()
+ yield
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links.clear()
+ rlm._link_failure_counts.clear()
+
+
+def _manager(*, identity=None, broadcast=None):
+ return rlm.RnsLinkManager(
+ self_identity_getter=lambda: identity,
+ reticulum_getter=lambda: None,
+ broadcast_event=broadcast or (lambda _payload: None),
+ )
+
+
+def test_split_aspect_requires_non_empty():
+ with pytest.raises(ValueError):
+ rlm._split_aspect("")
+ with pytest.raises(ValueError):
+ rlm._split_aspect("...")
+ assert rlm._split_aspect("microrn.mgmt") == ("microrn", ["mgmt"])
+ assert rlm._split_aspect("single") == ("single", [])
+ assert rlm._split_aspect("a.b.c.d") == ("a", ["b", "c", "d"])
+
+
+def test_get_cached_active_link_drops_inactive():
+ dest = bytes.fromhex("aa" * 16)
+ link = MagicMock()
+ link.status = object()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("microrn.mgmt", dest)] = link
+ assert rlm.get_cached_active_link("microrn.mgmt", dest) is None
+ assert ("microrn.mgmt", dest) not in rlm.rns_cached_links
+
+
+def test_get_cached_active_link_returns_active():
+ dest = bytes.fromhex("ab" * 16)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ assert rlm.get_cached_active_link("app.aspect", dest) is link
+
+
+def test_sweep_stale_links_and_orphan_counters():
+ dest_active = bytes.fromhex("11" * 16)
+ dest_stale = bytes.fromhex("22" * 16)
+ active = MagicMock()
+ active.status = rlm.RNS.Link.ACTIVE
+ stale = MagicMock()
+ stale.status = object()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("a", dest_active)] = active
+ rlm.rns_cached_links[("a", dest_stale)] = stale
+ rlm._link_failure_counts[("a", dest_stale)] = 1
+ rlm._link_failure_counts[("orphan", dest_stale)] = 9
+ rlm.sweep_stale_links()
+ with rlm._rns_links_lock:
+ assert ("a", dest_active) in rlm.rns_cached_links
+ assert ("a", dest_stale) not in rlm.rns_cached_links
+ assert ("orphan", dest_stale) not in rlm._link_failure_counts
+
+
+@pytest.mark.asyncio
+async def test_open_link_reuses_cached_active():
+ dest = bytes.fromhex("bb" * 16)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ link.identify = MagicMock()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+
+ identity = object()
+ manager = _manager(identity=identity)
+ phases = []
+ result_link, identified, failure = await manager.open_link(
+ dest,
+ "app.aspect",
+ auto_identify=True,
+ on_phase=phases.append,
+ )
+ assert result_link is link
+ assert identified is True
+ assert failure is None
+ assert phases == ["identifying"]
+ link.identify.assert_called_once_with(identity)
+
+
+@pytest.mark.asyncio
+async def test_open_link_cached_auto_identify_without_local_identity():
+ dest = bytes.fromhex("b0" * 16)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ manager = _manager(identity=None)
+ result_link, identified, failure = await manager.open_link(
+ dest,
+ "app.aspect",
+ auto_identify=True,
+ )
+ assert result_link is None
+ assert identified is False
+ assert failure == "no_local_identity"
+
+
+@pytest.mark.asyncio
+async def test_open_link_no_path(monkeypatch):
+ dest = bytes.fromhex("cc" * 16)
+ monkeypatch.setattr(
+ rlm.reticulum_pathfinding,
+ "prepare_fresh_path_request",
+ lambda *_args, **_kwargs: None,
+ )
+ monkeypatch.setattr(rlm.RNS.Transport, "has_path", lambda _dh: False)
+
+ manager = _manager()
+ link, identified, failure = await manager.open_link(
+ dest,
+ "app.aspect",
+ path_lookup_timeout=0.05,
+ )
+ assert link is None
+ assert identified is False
+ assert failure == "no_path_to_destination"
+
+
+@pytest.mark.asyncio
+async def test_open_link_no_identity_for_destination(monkeypatch):
+ dest = bytes.fromhex("c1" * 16)
+ monkeypatch.setattr(
+ rlm.reticulum_pathfinding,
+ "prepare_fresh_path_request",
+ lambda *_args, **_kwargs: None,
+ )
+ monkeypatch.setattr(rlm.RNS.Transport, "has_path", lambda _dh: True)
+ monkeypatch.setattr(rlm.RNS.Identity, "recall", lambda _dh: None)
+ manager = _manager()
+ link, identified, failure = await manager.open_link(dest, "app.aspect")
+ assert link is None
+ assert identified is False
+ assert failure == "no_identity_for_destination"
+
+
+@pytest.mark.asyncio
+async def test_open_link_establishment_timeout(monkeypatch):
+ dest = bytes.fromhex("c2" * 16)
+ identity = object()
+ monkeypatch.setattr(
+ rlm.reticulum_pathfinding,
+ "prepare_fresh_path_request",
+ lambda *_args, **_kwargs: None,
+ )
+ monkeypatch.setattr(rlm.RNS.Transport, "has_path", lambda _dh: True)
+ monkeypatch.setattr(rlm.RNS.Identity, "recall", lambda _dh: identity)
+
+ fake_link = MagicMock()
+ fake_link.status = object()
+ fake_link.teardown = MagicMock()
+ monkeypatch.setattr(rlm.RNS, "Destination", MagicMock())
+ monkeypatch.setattr(rlm.RNS, "Link", MagicMock(return_value=fake_link))
+
+ manager = _manager()
+ link, identified, failure = await manager.open_link(
+ dest,
+ "app.aspect",
+ link_establishment_timeout=0.05,
+ )
+ assert link is None
+ assert identified is False
+ assert failure == "link_establishment_timeout"
+ fake_link.teardown.assert_called()
+
+
+@pytest.mark.asyncio
+async def test_open_link_cancel_tears_down_half_built(monkeypatch):
+ dest = bytes.fromhex("c3" * 16)
+ identity = object()
+ monkeypatch.setattr(
+ rlm.reticulum_pathfinding,
+ "prepare_fresh_path_request",
+ lambda *_args, **_kwargs: None,
+ )
+ monkeypatch.setattr(rlm.RNS.Transport, "has_path", lambda _dh: True)
+ monkeypatch.setattr(rlm.RNS.Identity, "recall", lambda _dh: identity)
+
+ fake_link = MagicMock()
+ fake_link.status = object()
+ fake_link.teardown = MagicMock()
+ monkeypatch.setattr(rlm.RNS, "Destination", MagicMock())
+ monkeypatch.setattr(rlm.RNS, "Link", MagicMock(return_value=fake_link))
+
+ manager = _manager()
+ task = asyncio.create_task(
+ manager.open_link(dest, "app.aspect", link_establishment_timeout=5.0)
+ )
+ await asyncio.sleep(0.05)
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+ fake_link.teardown.assert_called()
+
+
+def test_close_uncaches_and_tears_down():
+ dest = bytes.fromhex("dd" * 16)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ manager = _manager()
+ assert manager.close(dest, "app.aspect") is True
+ link.teardown.assert_called_once()
+ assert rlm.get_cached_active_link("app.aspect", dest) is None
+
+
+def test_close_missing_link_returns_false():
+ manager = _manager()
+ assert manager.close(bytes.fromhex("00" * 16), "app.aspect") is False
+
+
+def test_identify_edge_cases():
+ dest = bytes.fromhex("d1" * 16)
+ manager = _manager(identity=None)
+ ok, reason = manager.identify(dest, "app.aspect")
+ assert ok is False
+ assert reason == "no_active_link"
+
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ ok, reason = manager.identify(dest, "app.aspect")
+ assert ok is False
+ assert reason == "no_local_identity"
+
+ identity = object()
+ manager = _manager(identity=identity)
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ ok, reason = manager.identify(dest, "app.aspect")
+ assert ok is True
+ assert reason is None
+ link.identify.assert_called_once_with(identity)
+
+ link.identify.side_effect = RuntimeError("boom")
+ ok, reason = manager.identify(dest, "app.aspect")
+ assert ok is False
+ assert reason.startswith("identify_failed:")
+
+
+def test_send_packet_edge_cases(monkeypatch):
+ dest = bytes.fromhex("d2" * 16)
+ manager = _manager()
+ ok, reason = manager.send_packet(dest, "app.aspect", b"x")
+ assert ok is False
+ assert reason == "no_active_link"
+
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ packet = MagicMock()
+ monkeypatch.setattr(rlm.RNS, "Packet", MagicMock(return_value=packet))
+ ok, reason = manager.send_packet(dest, "app.aspect", b"payload")
+ assert ok is True
+ assert reason is None
+ packet.send.assert_called_once()
+
+ packet.send.side_effect = RuntimeError("nope")
+ ok, reason = manager.send_packet(dest, "app.aspect", b"payload")
+ assert ok is False
+ assert reason.startswith("send_failed:")
+
+
+def test_request_requires_active_link():
+ manager = _manager()
+ with pytest.raises(RuntimeError, match="no_active_link"):
+ manager.request(
+ bytes.fromhex("d3" * 16),
+ "app.aspect",
+ "/status",
+ None,
+ response_callback=lambda _r: None,
+ failed_callback=lambda _r=None: None,
+ progress_callback=lambda _r: None,
+ )
+
+
+def test_request_resets_failures_on_success_and_recycles_on_fail():
+ dest = bytes.fromhex("d4" * 16)
+ key = ("app.aspect", dest)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ callbacks = {}
+
+ def fake_request(
+ path,
+ data=None,
+ response_callback=None,
+ failed_callback=None,
+ progress_callback=None,
+ timeout=None,
+ ):
+ callbacks["response"] = response_callback
+ callbacks["failed"] = failed_callback
+ return object()
+
+ link.request = fake_request
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[key] = link
+ rlm._link_failure_counts[key] = 1
+
+ manager = _manager()
+ manager.request(
+ dest,
+ "app.aspect",
+ "/status",
+ None,
+ response_callback=lambda _r: None,
+ failed_callback=lambda _r=None: None,
+ progress_callback=lambda _r: None,
+ )
+ callbacks["response"](MagicMock())
+ with rlm._rns_links_lock:
+ assert key not in rlm._link_failure_counts
+
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[key] = link
+ manager.request(
+ dest,
+ "app.aspect",
+ "/status",
+ None,
+ response_callback=lambda _r: None,
+ failed_callback=lambda _r=None: None,
+ progress_callback=lambda _r: None,
+ )
+ callbacks["failed"](None)
+ count, recycled = rlm._record_failure_and_maybe_recycle(key)
+ # First failure already recorded by wrapped failed callback.
+ assert count >= 1
+
+
+def test_record_failure_recycles_after_threshold():
+ dest = bytes.fromhex("ee" * 16)
+ key = ("app.aspect", dest)
+ link = MagicMock()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[key] = link
+ count, recycled = rlm._record_failure_and_maybe_recycle(key)
+ assert count == 1
+ assert recycled is False
+ count, recycled = rlm._record_failure_and_maybe_recycle(key)
+ assert recycled is True
+ assert key not in rlm.rns_cached_links
+ link.teardown.assert_called_once()
+
+
+def test_on_packet_and_link_closed_broadcast():
+ events = []
+ manager = _manager(broadcast=events.append)
+ dest = bytes.fromhex("f1" * 16)
+ link = MagicMock()
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ manager._on_packet("app.aspect", dest, b"\x00\x01")
+ assert events[-1]["type"] == "rns.link.event"
+ assert events[-1]["event"] == "packet_received"
+ assert events[-1]["payload_b64"] == base64.b64encode(b"\x00\x01").decode("ascii")
+
+ manager._on_link_closed("app.aspect", dest, link)
+ assert events[-1]["event"] == "link_closed"
+ assert ("app.aspect", dest) not in rlm.rns_cached_links
+
+
+def test_cache_helpers_are_thread_safe_under_churn():
+ dests = [bytes([i]) * 16 for i in range(8)]
+ errors = []
+
+ def worker(idx):
+ try:
+ for _ in range(50):
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ aspect = f"app.{idx}"
+ dest = dests[idx % len(dests)]
+ rlm._cache_link_if_active(aspect, dest, link)
+ rlm.get_cached_active_link(aspect, dest)
+ rlm._record_failure_and_maybe_recycle((aspect, dest))
+ rlm.sweep_stale_links()
+ except Exception as exc:
+ errors.append(exc)
+
+ threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+ assert errors == []
+
+
+def test_cancel_rns_link_tasks_for_client():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app._rns_link_tasks = {}
+ app._rns_request_receipts = {}
+ client = object()
+ loop = asyncio.new_event_loop()
+ try:
+ task = loop.create_task(asyncio.sleep(60))
+ app._track_rns_link_task(client, task)
+ app._rns_request_receipts[(client, "req")] = object()
+ app._cancel_rns_link_tasks_for_client(client)
+ assert task.cancelled() or task.cancelling()
+ assert (client, "req") not in app._rns_request_receipts
+ assert client not in app._rns_link_tasks
+ finally:
+ loop.close()
+
+
+def test_parse_dest_aspect_helpers():
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ dest, aspect, err = ReticulumMeshChat._rns_link_parse_dest_aspect(
+ {"destination_hash": "aa" * 16, "aspect": "microrn.mgmt"}
+ )
+ assert err is None
+ assert dest == bytes.fromhex("aa" * 16)
+ assert aspect == "microrn.mgmt"
+
+ _, _, err = ReticulumMeshChat._rns_link_parse_dest_aspect({})
+ assert err == "missing_destination_or_aspect"
+ _, _, err = ReticulumMeshChat._rns_link_parse_dest_aspect(
+ {"destination_hash": "zz", "aspect": "a"}
+ )
+ assert err == "invalid_destination_hash"
diff --git a/tests/backend/test_rns_link_plugin.py b/tests/backend/test_rns_link_plugin.py
new file mode 100644
index 00000000..45a9d61a
--- /dev/null
+++ b/tests/backend/test_rns_link_plugin.py
@@ -0,0 +1,337 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Plugin capability and hook coverage for the generic RNS Link API."""
+
+from __future__ import annotations
+
+import base64
+from unittest.mock import MagicMock
+
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+
+def _make_manager(tmp_path, app=None):
+ from meshchatx.src.backend.plugin_manager import PluginManager
+
+ return PluginManager(str(tmp_path), app=app)
+
+
+def _enable_with_link_perms(manager, plugin_id, *, managers=None, hooks=None):
+ manager.enable(plugin_id)
+ record = manager._plugins[plugin_id]
+ perms = record.manifest.setdefault("permissions", {})
+ granted = list(record.granted_permissions or [])
+ if managers is not None:
+ perms["managers"] = managers
+ for manager_name in managers:
+ perm_id = f"managers:{manager_name}"
+ if perm_id not in granted:
+ granted.append(perm_id)
+ if hooks is not None:
+ perms["hooks"] = hooks
+ for hook in hooks:
+ perm_id = f"hooks:{hook}"
+ if perm_id not in granted:
+ granted.append(perm_id)
+ record.granted_permissions = granted
+ return record
+
+
+class FakeLinkManager:
+ def __init__(self):
+ self.opened = []
+ self.identified = []
+ self.sent = []
+ self.closed = []
+ self.requested = []
+
+ async def open_link(self, dest_hash, aspect, *, auto_identify=False, on_phase=None):
+ self.opened.append((dest_hash, aspect, auto_identify))
+ if on_phase:
+ on_phase("establishing_link")
+ return object(), bool(auto_identify), None
+
+ def identify(self, dest_hash, aspect):
+ self.identified.append((dest_hash, aspect))
+ return True, None
+
+ def send_packet(self, dest_hash, aspect, payload):
+ self.sent.append((dest_hash, aspect, payload))
+ return True, None
+
+ def close(self, dest_hash, aspect):
+ self.closed.append((dest_hash, aspect))
+ return True
+
+ def request(
+ self,
+ dest_hash,
+ aspect,
+ path,
+ data,
+ response_callback,
+ failed_callback,
+ progress_callback,
+ timeout=None,
+ ):
+ self.requested.append((dest_hash, aspect, path, data, timeout))
+ receipt = MagicMock()
+ receipt.response = {"ok": True, "path": path}
+ response_callback(receipt)
+ return receipt
+
+
+class TestRnsLinkPluginCapabilities:
+ def test_all_capabilities_roundtrip(self, tmp_path):
+ fake = FakeLinkManager()
+
+ class FakeApp:
+ reticulum = object()
+ rns_link_manager = fake
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ _enable_with_link_perms(
+ manager,
+ plugin_id,
+ managers=[
+ "rnsLink.open",
+ "rnsLink.identify",
+ "rnsLink.request",
+ "rnsLink.send",
+ "rnsLink.close",
+ ],
+ )
+ dest = "aa" * 16
+ aspect = "microrn.mgmt"
+ opened = manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": dest, "aspect": aspect, "auto_identify": True},
+ )
+ assert opened["ok"] is True
+ assert opened["identified"] is True
+
+ identified = manager.call_manager(
+ plugin_id,
+ "rnsLink.identify",
+ {"destination_hash": dest, "aspect": aspect},
+ )
+ assert identified["ok"] is True
+
+ from RNS.vendor import umsgpack
+
+ body = umsgpack.packb({"cmd": "status"})
+ requested = manager.call_manager(
+ plugin_id,
+ "rnsLink.request",
+ {
+ "destination_hash": dest,
+ "aspect": aspect,
+ "path": "/status",
+ "data_b64": base64.b64encode(body).decode("ascii"),
+ "timeout": 1,
+ },
+ )
+ assert requested["ok"] is True
+ assert "body_b64" in requested
+
+ sent = manager.call_manager(
+ plugin_id,
+ "rnsLink.send",
+ {
+ "destination_hash": dest,
+ "aspect": aspect,
+ "payload_b64": base64.b64encode(b"hi").decode("ascii"),
+ },
+ )
+ assert sent["ok"] is True
+
+ closed = manager.call_manager(
+ plugin_id,
+ "rnsLink.close",
+ {"destination_hash": dest, "aspect": aspect},
+ )
+ assert closed["ok"] is True
+ assert len(fake.opened) == 2 # open + request open
+ assert fake.closed
+
+ def test_invalid_args_raise_value_error(self, tmp_path):
+ fake = FakeLinkManager()
+
+ class FakeApp:
+ reticulum = object()
+ rns_link_manager = fake
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ _enable_with_link_perms(
+ manager,
+ plugin_id,
+ managers=["rnsLink.open", "rnsLink.send", "rnsLink.request"],
+ )
+ with pytest.raises(ValueError):
+ manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": "zz", "aspect": "a"},
+ )
+ with pytest.raises(ValueError):
+ manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": "aa" * 16},
+ )
+ with pytest.raises(ValueError):
+ manager.call_manager(
+ plugin_id,
+ "rnsLink.send",
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "a",
+ "payload_b64": "!!!",
+ },
+ )
+ with pytest.raises(ValueError):
+ manager.call_manager(
+ plugin_id,
+ "rnsLink.request",
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "a",
+ "path": "",
+ },
+ )
+
+ def test_request_open_failure_propagates(self, tmp_path):
+ class FailingOpen:
+ async def open_link(self, *_args, **_kwargs):
+ return None, False, "no_path_to_destination"
+
+ class FakeApp:
+ reticulum = object()
+ rns_link_manager = FailingOpen()
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ _enable_with_link_perms(manager, plugin_id, managers=["rnsLink.request"])
+ result = manager.call_manager(
+ plugin_id,
+ "rnsLink.request",
+ {
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "path": "/status",
+ },
+ )
+ assert result["ok"] is False
+ assert result["failure_reason"] == "no_path_to_destination"
+
+ def test_event_hook_requires_permission(self, tmp_path):
+ events = []
+
+ class FakeApp:
+ plugins_enabled = True
+
+ def websocket_broadcast(self, _message):
+ return None
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ manager.enable(plugin_id)
+ manager.dispatch_hook = lambda *args: events.append(args)
+ manager.on_rns_link_event(
+ {
+ "type": "rns.link.event",
+ "event": "packet_received",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "payload_b64": "AA==",
+ }
+ )
+ assert events == []
+
+ _enable_with_link_perms(
+ manager,
+ plugin_id,
+ hooks=["announce.received", "rns.link.event"],
+ )
+ manager.on_rns_link_event(
+ {
+ "type": "rns.link.event",
+ "event": "packet_received",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "payload_b64": "AA==",
+ }
+ )
+ assert events
+ assert events[-1][1] == "rns.link.event"
+
+ @given(
+ dest=st.binary(min_size=16, max_size=16).map(lambda b: b.hex()),
+ aspect=st.text(
+ alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz."),
+ min_size=1,
+ max_size=32,
+ ).filter(lambda v: any(ch.isalpha() for ch in v)),
+ )
+ @settings(
+ max_examples=30,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+ )
+ def test_open_close_property(self, tmp_path, dest, aspect):
+ fake = FakeLinkManager()
+
+ class FakeApp:
+ reticulum = object()
+ rns_link_manager = fake
+
+ manager = _make_manager(tmp_path, app=FakeApp())
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mesh-observatory"
+ _enable_with_link_perms(
+ manager,
+ plugin_id,
+ managers=["rnsLink.open", "rnsLink.close"],
+ )
+ opened = manager.call_manager(
+ plugin_id,
+ "rnsLink.open",
+ {"destination_hash": dest, "aspect": aspect},
+ )
+ closed = manager.call_manager(
+ plugin_id,
+ "rnsLink.close",
+ {"destination_hash": dest, "aspect": aspect},
+ )
+ assert opened["ok"] is True
+ assert closed["ok"] is True
+ assert opened["destination_hash"] == dest
+ assert closed["aspect"] == aspect
+
+
+def test_app_broadcast_fans_out_plugin_events(tmp_path):
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ events = []
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app.plugin_manager = MagicMock()
+ app.plugin_manager.on_rns_link_event = lambda payload: events.append(payload)
+ app._broadcast_to_websocket_clients = MagicMock()
+ payload = {
+ "type": "rns.link.event",
+ "event": "link_closed",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ }
+ app._on_rns_link_broadcast(payload)
+ app._broadcast_to_websocket_clients.assert_called_once_with(payload)
+ assert events == [payload]
diff --git a/tests/backend/test_self_check.py b/tests/backend/test_self_check.py
index 10ac3781..ec6665e0 100644
--- a/tests/backend/test_self_check.py
+++ b/tests/backend/test_self_check.py
@@ -6,6 +6,8 @@ from __future__ import annotations
from types import SimpleNamespace
+import pytest
+
from meshchatx.src.backend import self_check
@@ -174,3 +176,50 @@ def test_self_check_labels_cover_schema_keys():
required = set(SELF_TEST_SCHEMA["required"])
assert set(self_check.SELF_CHECK_LABELS) == required
+
+
+@pytest.mark.asyncio
+async def test_probe_rns_link_api_accepts_no_active_link():
+ from unittest.mock import AsyncMock
+
+ from aiohttp import WSMsgType
+
+ class FakeMsg:
+ def __init__(self, payload):
+ self.type = WSMsgType.TEXT
+ self.data = payload
+
+ ws = AsyncMock()
+ ws.send_str = AsyncMock()
+ ws.receive = AsyncMock(
+ return_value=FakeMsg(
+ '{"type":"rns.link.close","request_id":"self-check-rns-link",'
+ '"status":"failure","failure_reason":"no_active_link"}'
+ )
+ )
+ result = await self_check._probe_rns_link_api(ws, timeout=1)
+ assert result["status"] == "ok"
+ ws.send_str.assert_awaited()
+
+
+@pytest.mark.asyncio
+async def test_probe_rns_link_api_rejects_unexpected_failure():
+ from unittest.mock import AsyncMock
+
+ from aiohttp import WSMsgType
+
+ class FakeMsg:
+ def __init__(self, payload):
+ self.type = WSMsgType.TEXT
+ self.data = payload
+
+ ws = AsyncMock()
+ ws.send_str = AsyncMock()
+ ws.receive = AsyncMock(
+ return_value=FakeMsg(
+ '{"type":"rns.link.close","request_id":"self-check-rns-link",'
+ '"status":"failure","failure_reason":"boom"}'
+ )
+ )
+ result = await self_check._probe_rns_link_api(ws, timeout=1)
+ assert result["status"] == "failed"
diff --git a/tests/backend/test_smoke_extended.py b/tests/backend/test_smoke_extended.py
index 692b36e6..1a80c7cd 100644
--- a/tests/backend/test_smoke_extended.py
+++ b/tests/backend/test_smoke_extended.py
@@ -43,6 +43,23 @@ def test_import_all_backend_modules():
pytest.fail(f"Failed to import {full_module_name}: {e}")
+def test_rns_link_manager_smoke():
+ """Smoke: RnsLinkManager constructs and exposes the public transport API."""
+ from meshchatx.src.backend.rns_link_manager import RnsLinkManager
+
+ manager = RnsLinkManager(
+ self_identity_getter=lambda: None,
+ reticulum_getter=lambda: None,
+ broadcast_event=lambda _payload: None,
+ )
+ assert callable(manager.open_link)
+ assert callable(manager.identify)
+ assert callable(manager.request)
+ assert callable(manager.send_packet)
+ assert callable(manager.close)
+ assert manager.close(b"\x00" * 16, "meshchatx.smoke") is False
+
+
def test_database_migration_smoke():
"""Smoke test for database migrations from version 0 to latest."""
from meshchatx.src.backend.database.provider import DatabaseProvider
diff --git a/tests/backend/ws_contract_helpers.py b/tests/backend/ws_contract_helpers.py
index d3c1ad03..66d171b4 100644
--- a/tests/backend/ws_contract_helpers.py
+++ b/tests/backend/ws_contract_helpers.py
@@ -14,11 +14,14 @@ _CLIENT_HANDLER_RE = re.compile(
_WS_SEND_STR_RE = re.compile(
r"client\.send_str\s*\(\s*json\.dumps\s*\(\s*(\{[\s\S]*?\})\s*,?\s*\)",
)
+_RNS_LINK_SEND_RE = re.compile(
+ r"_rns_link_send\s*\(\s*client\s*,\s*(\{[\s\S]*?\})\s*,?\s*\)",
+)
_WS_TYPE_LITERAL_RE = re.compile(
r"[\"']type[\"']\s*:\s*[\"']([^\"']+)[\"']",
)
_BROADCAST_CALL_RE = re.compile(
- r"(?:websocket_broadcast|_broadcast_websocket_message)\s*\(",
+ r"(?:websocket_broadcast|_broadcast_websocket_message|_broadcast_to_websocket_clients)\s*\(",
)
@@ -42,16 +45,23 @@ def _extract_type_literals_from_dict_literal(blob: str) -> str | None:
def extract_client_direct_response_types(meshchat_py: Path) -> list[str]:
text = meshchat_py.read_text(encoding="utf-8")
+ # Include rns.link.* helpers that live immediately after the WS dispatcher.
start = text.find("async def on_websocket_data_received")
if start < 0:
return []
- end = text.find("\n async def ", start + 1)
+ end = text.find("\n async def websocket_broadcast", start + 1)
+ if end < 0:
+ end = text.find("\n async def ", start + 1)
block = text[start:end] if end > start else text[start:]
types: set[str] = set()
for blob in _WS_SEND_STR_RE.findall(block):
msg_type = _extract_type_literals_from_dict_literal(blob)
if msg_type:
types.add(msg_type)
+ for blob in _RNS_LINK_SEND_RE.findall(block):
+ msg_type = _extract_type_literals_from_dict_literal(blob)
+ if msg_type:
+ types.add(msg_type)
return sorted(types)
@@ -63,6 +73,13 @@ def extract_server_broadcast_types(meshchat_py: Path) -> list[str]:
type_match = _WS_TYPE_LITERAL_RE.search(chunk)
if type_match:
types.add(type_match.group(1))
+ link_manager = meshchat_py.parent / "src" / "backend" / "rns_link_manager.py"
+ if link_manager.is_file():
+ link_text = link_manager.read_text(encoding="utf-8")
+ for type_match in _WS_TYPE_LITERAL_RE.finditer(link_text):
+ msg_type = type_match.group(1)
+ if msg_type.startswith("rns.link."):
+ types.add(msg_type)
for fn_name in ("send_config_to_websocket_clients",):
start = text.find(f"async def {fn_name}")
if start < 0:
diff --git a/tests/backend/ws_json_contract_schemas.py b/tests/backend/ws_json_contract_schemas.py
index 6aa99044..8a5610a8 100644
--- a/tests/backend/ws_json_contract_schemas.py
+++ b/tests/backend/ws_json_contract_schemas.py
@@ -33,6 +33,21 @@ _WS_BOOL = {"type": "boolean"}
WS_MESSAGE_SCHEMAS: dict[str, dict] = {
"ping": _ws_type("ping"),
"pong": _ws_type("pong"),
+ "error": _ws_type(
+ "error",
+ required=["message"],
+ properties={"message": _WS_STRING},
+ ),
+ "startup_status": _ws_type(
+ "startup_status",
+ required=["status"],
+ properties={
+ "status": _WS_STRING,
+ "stage": _WS_STRING,
+ "network_ready": _WS_BOOL,
+ "error": _WS_STRING,
+ },
+ ),
"config.set": _ws_type(
"config.set", required=["config"], properties={"config": _WS_OBJECT}
),
@@ -168,11 +183,93 @@ WS_MESSAGE_SCHEMAS: dict[str, dict] = {
"rncp.transfer.progress": _ws_type("rncp.transfer.progress"),
"rncp.send.completed": _ws_type("rncp.send.completed"),
"rncp.fetch.completed": _ws_type("rncp.fetch.completed"),
+ "rns.link.open": _ws_type(
+ "rns.link.open",
+ required=["request_id"],
+ properties={
+ "request_id": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "status": _WS_STRING,
+ "phase": _WS_STRING,
+ "identified": _WS_BOOL,
+ "failure_reason": {"type": ["string", "null"]},
+ "auto_identify": _WS_BOOL,
+ },
+ ),
+ "rns.link.identify": _ws_type(
+ "rns.link.identify",
+ required=["request_id"],
+ properties={
+ "request_id": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "status": _WS_STRING,
+ "failure_reason": {"type": ["string", "null"]},
+ },
+ ),
+ "rns.link.request": _ws_type(
+ "rns.link.request",
+ required=["request_id"],
+ properties={
+ "request_id": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "path": _WS_STRING,
+ "data_b64": _WS_STRING,
+ "body_b64": _WS_STRING,
+ "status": _WS_STRING,
+ "phase": _WS_STRING,
+ "progress": {"type": "number"},
+ "failure_reason": {"type": ["string", "null"]},
+ "timeout": {"type": "number"},
+ },
+ ),
+ "rns.link.send": _ws_type(
+ "rns.link.send",
+ required=["request_id"],
+ properties={
+ "request_id": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "payload_b64": _WS_STRING,
+ "status": _WS_STRING,
+ "failure_reason": {"type": ["string", "null"]},
+ },
+ ),
+ "rns.link.close": _ws_type(
+ "rns.link.close",
+ required=["request_id"],
+ properties={
+ "request_id": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "status": _WS_STRING,
+ "failure_reason": {"type": ["string", "null"]},
+ },
+ ),
+ "rns.link.event": _ws_type(
+ "rns.link.event",
+ required=["event", "destination_hash", "aspect"],
+ properties={
+ "event": _WS_STRING,
+ "destination_hash": _WS_STRING,
+ "aspect": _WS_STRING,
+ "payload_b64": _WS_STRING,
+ },
+ ),
}
WS_MESSAGE_SAMPLES: dict[str, dict] = {
"ping": {"type": "ping"},
"pong": {"type": "pong"},
+ "error": {"type": "error", "message": "Authentication required"},
+ "startup_status": {
+ "type": "startup_status",
+ "status": "ok",
+ "stage": "ready",
+ "network_ready": True,
+ },
"config.set": {"type": "config.set", "config": {"display_name": "Test"}},
"config": {"type": "config", "config": {"display_name": "Test"}},
"announced": {"type": "announced"},
@@ -283,6 +380,51 @@ WS_MESSAGE_SAMPLES: dict[str, dict] = {
"rncp.transfer.progress": {"type": "rncp.transfer.progress", "percent": 50},
"rncp.send.completed": {"type": "rncp.send.completed"},
"rncp.fetch.completed": {"type": "rncp.fetch.completed"},
+ "rns.link.open": {
+ "type": "rns.link.open",
+ "request_id": "req-1",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "status": "success",
+ "identified": False,
+ },
+ "rns.link.identify": {
+ "type": "rns.link.identify",
+ "request_id": "req-2",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "status": "success",
+ },
+ "rns.link.request": {
+ "type": "rns.link.request",
+ "request_id": "req-3",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "path": "/status",
+ "status": "success",
+ "body_b64": "",
+ },
+ "rns.link.send": {
+ "type": "rns.link.send",
+ "request_id": "req-4",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "payload_b64": "",
+ "status": "success",
+ },
+ "rns.link.close": {
+ "type": "rns.link.close",
+ "request_id": "req-5",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ "status": "success",
+ },
+ "rns.link.event": {
+ "type": "rns.link.event",
+ "event": "link_closed",
+ "destination_hash": "aa" * 16,
+ "aspect": "microrn.mgmt",
+ },
}
diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js
index 272fcff9..75677829 100644
--- a/tests/e2e/smoke.spec.js
+++ b/tests/e2e/smoke.spec.js
@@ -47,6 +47,7 @@ test.describe("MeshChatX E2E (Vite + Python backend)", () => {
"http_favourites_good",
"http_telephone_good",
"websocket_good",
+ "websocket_rns_link_good",
"bots_lifecycle",
];
for (const key of keys) {
diff --git a/tests/electron/loadingStatusProbe.test.js b/tests/electron/loadingStatusProbe.test.js
new file mode 100644
index 00000000..4328fa34
--- /dev/null
+++ b/tests/electron/loadingStatusProbe.test.js
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { createRequire } from "module";
+
+const require = createRequire(import.meta.url);
+const probe = require("../../electron/loadingStatusProbe.js");
+
+describe("electron/loadingStatusProbe", () => {
+ it("parseStatusJson returns null for invalid JSON", () => {
+ expect(probe.parseStatusJson("{")).toBeNull();
+ expect(probe.parseStatusJson(null)).toBeNull();
+ });
+
+ it("evaluateStatusResponse accepts starting before network ready", () => {
+ const result = probe.evaluateStatusResponse(
+ 200,
+ JSON.stringify({
+ status: "starting",
+ stage: "rns",
+ network_ready: false,
+ })
+ );
+ expect(result.ok).toBe(true);
+ expect(result.networkReady).toBe(false);
+ expect(result.stage).toBe("rns");
+ });
+
+ it("evaluateStatusResponse accepts ok when network ready", () => {
+ const result = probe.evaluateStatusResponse(
+ 200,
+ JSON.stringify({
+ status: "ok",
+ stage: "ready",
+ network_ready: true,
+ })
+ );
+ expect(result.ok).toBe(true);
+ expect(result.networkReady).toBe(true);
+ });
+
+ it("evaluateStatusResponse rejects failed startup", () => {
+ const result = probe.evaluateStatusResponse(
+ 200,
+ JSON.stringify({
+ status: "failed",
+ stage: "failed",
+ error: "boom",
+ network_ready: false,
+ })
+ );
+ expect(result.ok).toBe(false);
+ expect(result.failure.kind).toBe("startup-failed");
+ expect(result.failure.error).toBe("boom");
+ });
+
+ it("evaluateStatusResponse rejects non-200", () => {
+ const result = probe.evaluateStatusResponse(503, '{"status":"starting"}');
+ expect(result.ok).toBe(false);
+ expect(result.failure.kind).toBe("http-error");
+ expect(result.failure.status).toBe(503);
+ });
+
+ it("evaluateStatusResponse rejects invalid payload", () => {
+ expect(probe.evaluateStatusResponse(200, "not-json").ok).toBe(false);
+ expect(probe.evaluateStatusResponse(200, '{"status":"nope"}').ok).toBe(false);
+ });
+
+ it("evaluateStatusResponse fuzzes common stage transitions", () => {
+ const stages = ["http", "starting", "rns", "identity"];
+ for (const stage of stages) {
+ const result = probe.evaluateStatusResponse(
+ 200,
+ JSON.stringify({ status: "starting", stage, network_ready: false })
+ );
+ expect(result.ok).toBe(true);
+ expect(result.stage).toBe(stage);
+ expect(result.networkReady).toBe(false);
+ }
+ });
+});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 2599f46a..ea5d6502 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -190,6 +190,49 @@ describe("behavior contracts: Reticulum instance settings", () => {
});
});
+describe("behavior contracts: RNS Link API", () => {
+ it("keeps generic rns.link transport wired for plugins and self-check", () => {
+ const meshchat = readSource("meshchatx/meshchat.py");
+ expect(meshchat).toContain("rns.link.open");
+ expect(meshchat).toContain("rns.link.request");
+ expect(meshchat).toContain("websocket_rns_link_good");
+ const manager = readSource("meshchatx/src/backend/rns_link_manager.py");
+ expect(manager).toContain("class RnsLinkManager");
+ expect(manager).toContain("rns.link.event");
+ const plugins = readSource("meshchatx/src/backend/plugin_manager.py");
+ expect(plugins).toContain("rnsLink.open");
+ expect(plugins).toContain("rns.link.event");
+ const selfCheck = readSource("meshchatx/src/backend/self_check.py");
+ expect(selfCheck).toContain("websocket_rns_link_good");
+ expect(selfCheck).toContain("_probe_rns_link_api");
+ const guard = readSource("meshchatx/src/backend/websocket_config_guard.py");
+ expect(guard).toContain("rns.link.open");
+ expect(guard).toContain("rns.link.close");
+ });
+});
+
+describe("behavior contracts: plugin install permissions", () => {
+ it("previews ZIP installs and lists network endpoints before grant", () => {
+ const meshchat = readSource("meshchatx/meshchat.py");
+ expect(meshchat).toContain("/api/v1/plugins/preview");
+ expect(meshchat).toContain("granted_permissions");
+ const section = readSource("meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue");
+ expect(section).toContain("PluginInstallDialog");
+ expect(section).toContain("/api/v1/plugins/preview");
+ expect(section).toContain("granted_permissions");
+ const dialog = readSource("meshchatx/src/frontend/components/settings/PluginInstallDialog.vue");
+ expect(dialog).toContain("network_endpoints");
+ expect(dialog).toContain("grantedMap");
+ const perms = readSource("meshchatx/src/backend/plugin_permissions.py");
+ expect(perms).toContain("collect_network_endpoints");
+ expect(perms).toContain('permission_id_for_network("fetch")');
+ expect(perms).toContain("KNOWN_NETWORK");
+ const manager = readSource("meshchatx/src/backend/plugin_manager.py");
+ expect(manager).toContain("preview_from_zip_bytes");
+ expect(manager).toContain("granted_allows_network_fetch");
+ });
+});
+
describe("behavior contracts: network visualiser performance", () => {
it("keeps lean physics and edge-hide options for large meshes", () => {
const src = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");
diff --git a/tests/frontend/networkStartupWait.test.js b/tests/frontend/networkStartupWait.test.js
new file mode 100644
index 00000000..ae73df3d
--- /dev/null
+++ b/tests/frontend/networkStartupWait.test.js
@@ -0,0 +1,138 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ STARTUP_STAGE_LABELS,
+ interpretStartupStatus,
+ waitForNetworkReady,
+} from "../../meshchatx/src/frontend/js/networkStartupWait.js";
+
+describe("networkStartupWait", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("interpretStartupStatus marks ready for ok", () => {
+ expect(interpretStartupStatus({ status: "ok", network_ready: true })).toEqual({
+ kind: "ready",
+ stage: "ready",
+ });
+ });
+
+ it("interpretStartupStatus marks ready when network_ready alone", () => {
+ expect(interpretStartupStatus({ status: "starting", network_ready: true, stage: "identity" })).toEqual({
+ kind: "ready",
+ stage: "identity",
+ });
+ });
+
+ it("interpretStartupStatus marks failed", () => {
+ expect(interpretStartupStatus({ status: "failed", error: "boom", stage: "failed" })).toEqual({
+ kind: "failed",
+ stage: "failed",
+ error: "boom",
+ });
+ });
+
+ it("interpretStartupStatus maps starting stages to labels", () => {
+ for (const stage of Object.keys(STARTUP_STAGE_LABELS)) {
+ if (stage === "ready" || stage === "failed") {
+ continue;
+ }
+ const result = interpretStartupStatus({ status: "starting", stage });
+ expect(result.kind).toBe("starting");
+ expect(result.label).toBe(STARTUP_STAGE_LABELS[stage]);
+ }
+ });
+
+ it("interpretStartupStatus rejects invalid payloads", () => {
+ expect(interpretStartupStatus(null).kind).toBe("invalid");
+ expect(interpretStartupStatus("x").kind).toBe("invalid");
+ expect(interpretStartupStatus({ status: "nope" }).kind).toBe("invalid");
+ });
+
+ it("waitForNetworkReady resolves when status becomes ok", async () => {
+ let calls = 0;
+ const lines = [];
+ const fetchImpl = vi.fn(async () => {
+ calls += 1;
+ if (calls < 3) {
+ return {
+ ok: true,
+ json: async () => ({ status: "starting", stage: "rns", network_ready: false }),
+ };
+ }
+ return {
+ ok: true,
+ json: async () => ({ status: "ok", stage: "ready", network_ready: true }),
+ };
+ });
+ const ready = await waitForNetworkReady({
+ fetchImpl,
+ sleep: async () => {},
+ timeoutMs: 5000,
+ onLine: (text) => lines.push(text),
+ });
+ expect(ready).toBe(true);
+ expect(lines).toContain(STARTUP_STAGE_LABELS.rns);
+ expect(fetchImpl).toHaveBeenCalled();
+ });
+
+ it("waitForNetworkReady returns false on failed status", async () => {
+ const errors = [];
+ const ready = await waitForNetworkReady({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => ({ status: "failed", error: "RNS died" }),
+ }),
+ sleep: async () => {},
+ timeoutMs: 1000,
+ onLine: () => {},
+ onErrorState: () => errors.push("error"),
+ });
+ expect(ready).toBe(false);
+ expect(errors).toEqual(["error"]);
+ });
+
+ it("waitForNetworkReady keeps polling through fetch errors", async () => {
+ let calls = 0;
+ const lines = [];
+ const ready = await waitForNetworkReady({
+ fetchImpl: async () => {
+ calls += 1;
+ if (calls === 1) {
+ throw new Error("offline");
+ }
+ return {
+ ok: true,
+ json: async () => ({ status: "ok", network_ready: true }),
+ };
+ },
+ sleep: async () => {},
+ timeoutMs: 5000,
+ onLine: (text) => lines.push(text),
+ });
+ expect(ready).toBe(true);
+ expect(lines).toContain("Still starting…");
+ });
+
+ it("waitForNetworkReady times out", async () => {
+ let now = 0;
+ const errors = [];
+ const lines = [];
+ const ready = await waitForNetworkReady({
+ fetchImpl: async () => ({
+ ok: true,
+ json: async () => ({ status: "starting", stage: "identity", network_ready: false }),
+ }),
+ now: () => now,
+ sleep: async () => {
+ now += 500;
+ },
+ timeoutMs: 1000,
+ onLine: (text) => lines.push(text),
+ onErrorState: () => errors.push("error"),
+ });
+ expect(ready).toBe(false);
+ expect(errors).toEqual(["error"]);
+ expect(lines.at(-1)).toContain("timed out");
+ });
+});
diff --git a/tests/frontend/pluginManifest.test.js b/tests/frontend/pluginManifest.test.js
index 282a8947..4297a004 100644
--- a/tests/frontend/pluginManifest.test.js
+++ b/tests/frontend/pluginManifest.test.js
@@ -5,6 +5,7 @@ import {
validatePluginManifest,
manifestPermissionSummary,
} from "../../meshchatx/src/frontend/js/plugins/pluginManifest.js";
+import { declaredPermissionIds, permissionLabel } from "../../meshchatx/src/frontend/js/plugins/pluginPermissions.js";
describe("pluginManifest", () => {
it("validates a minimal manifest", () => {
@@ -27,15 +28,34 @@ describe("pluginManifest", () => {
).toThrow(/apiVersion/);
});
- it("summarizes permissions", () => {
- const lines = manifestPermissionSummary({
+ it("summarizes permissions with labels", () => {
+ const lines = manifestPermissionSummary(
+ {
+ permissions: {
+ hooks: ["announce.received"],
+ managers: ["destinationPath.read"],
+ storage: "isolated",
+ network: "fetch",
+ },
+ },
+ (key) => (key === "plugins.permissions.network.fetch" ? "Make outbound internet HTTP requests" : key)
+ );
+ expect(lines.join(" ")).toContain("hooks:announce.received");
+ expect(lines.join(" ")).toContain("Make outbound internet HTTP requests");
+ });
+
+ it("builds declared permission ids including network.fetch", () => {
+ const ids = declaredPermissionIds({
permissions: {
- hooks: ["announce.received"],
- managers: ["destinationPath.read"],
- storage: "isolated",
+ hooks: ["rns.link.event"],
+ network: "http",
},
});
- expect(lines.join(" ")).toContain("announce.received");
- expect(lines.join(" ")).toContain("destinationPath.read");
+ expect(ids).toContain("hooks:rns.link.event");
+ expect(ids).toContain("network:fetch");
+ });
+
+ it("labels unknown permissions with the raw id", () => {
+ expect(permissionLabel("custom:thing", (key) => key)).toBe("custom:thing");
});
});
diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
index 55e5d4d5..5918bc81 100644
--- a/tests/frontend/settingsTabs.test.js
+++ b/tests/frontend/settingsTabs.test.js
@@ -68,6 +68,8 @@ describe("settingsTabs", () => {
expect(settingsTabForSection("appearance")).toBe("general");
expect(settingsTabForSection("messages")).toBe("messages");
expect(settingsTabForSection("archiver")).toBe("nomad");
+ expect(settingsTabForSection("plugins")).toBe("plugins");
+ expect(settingsTabForSection("selftest")).toBe("maintenance");
expect(settingsTabForSection("unknown-section")).toBeNull();
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────